mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 00:27:43 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Button,
|
|
Combobox,
|
|
Flex,
|
|
Grid,
|
|
InputErrorsTooltip,
|
|
InputField,
|
|
Link,
|
|
List,
|
|
ListItem,
|
|
ProChip,
|
|
rawTheme,
|
|
ScrollArea,
|
|
SearchField,
|
|
SmallIconButton,
|
|
Text,
|
|
theme,
|
|
Tooltip,
|
|
buttonStyle,
|
|
} from "@webstudio-is/design-system";
|
|
import { InfoCircleIcon, TrashIcon } from "@webstudio-is/icons";
|
|
import { useStore } from "@nanostores/react";
|
|
import {
|
|
createBasicAuthRoute,
|
|
serializeWsAuth,
|
|
type WsAuthRoute,
|
|
} from "@webstudio-is/wsauth";
|
|
import { validateBasicAuthCredentials } from "@webstudio-is/project-build/runtime";
|
|
import { $permissions } from "~/shared/nano-states";
|
|
import { $pages, $projectSettings } from "~/shared/sync/data-stores";
|
|
import { getExistingRoutePaths, sectionSpacing } from "./utils";
|
|
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
|
import {
|
|
parseProjectAuthRoutes,
|
|
validateProjectAuthRoute,
|
|
} from "@webstudio-is/project-build/contracts";
|
|
|
|
const saveAuthRoutes = (authRoutes: WsAuthRoute[]) => {
|
|
executeRuntimeMutation({
|
|
id: "projectSettings.update",
|
|
input: {
|
|
meta: {
|
|
auth: authRoutes.length === 0 ? null : serializeWsAuth(authRoutes),
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
export const SectionAuth = () => {
|
|
const { allowAuth } = useStore($permissions);
|
|
const pages = useStore($pages);
|
|
const projectSettings = useStore($projectSettings);
|
|
const routeRef = useRef<HTMLInputElement>(null);
|
|
const [authRoutes, setAuthRoutes] = useState(() => {
|
|
return parseProjectAuthRoutes($projectSettings.get()?.meta.auth).routes;
|
|
});
|
|
const [route, setRoute] = useState("");
|
|
const [login, setLogin] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [routeErrors, setRouteErrors] = useState<string[]>([]);
|
|
const [loginErrors, setLoginErrors] = useState<string[]>([]);
|
|
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const authContent = projectSettings?.meta.auth;
|
|
const parseResult = useMemo(() => {
|
|
return parseProjectAuthRoutes(authContent);
|
|
}, [authContent]);
|
|
const parseErrors = parseResult.errors;
|
|
|
|
useEffect(() => {
|
|
setAuthRoutes(parseResult.routes);
|
|
}, [parseResult.routes]);
|
|
|
|
const existingPaths = getExistingRoutePaths(pages);
|
|
const routeSuggestions = ["/", ...Array.from(existingPaths).sort()];
|
|
const filteredAuthRoutes = searchQuery
|
|
? authRoutes.filter((authRoute) => {
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
authRoute.route.toLowerCase().includes(query) ||
|
|
authRoute.auth.login.toLowerCase().includes(query)
|
|
);
|
|
})
|
|
: authRoutes;
|
|
|
|
const handleRouteChange = (value: string) => {
|
|
setRoute(value);
|
|
setRouteErrors(validateProjectAuthRoute(value.trim(), authRoutes));
|
|
};
|
|
|
|
const handleSave = (nextAuthRoutes: WsAuthRoute[]) => {
|
|
setAuthRoutes(nextAuthRoutes);
|
|
saveAuthRoutes(nextAuthRoutes);
|
|
};
|
|
|
|
const handleAddAuthRoute = () => {
|
|
const nextRoute = route.trim();
|
|
const nextRouteErrors = validateProjectAuthRoute(nextRoute, authRoutes);
|
|
const basicAuthErrors = validateBasicAuthCredentials({
|
|
login,
|
|
password,
|
|
});
|
|
const nextLoginErrors = basicAuthErrors?.login ?? [];
|
|
const nextPasswordErrors = basicAuthErrors?.password ?? [];
|
|
|
|
setRouteErrors(nextRouteErrors);
|
|
setLoginErrors(nextLoginErrors);
|
|
setPasswordErrors(nextPasswordErrors);
|
|
|
|
if (
|
|
nextRouteErrors.length > 0 ||
|
|
nextLoginErrors.length > 0 ||
|
|
nextPasswordErrors.length > 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
handleSave([
|
|
createBasicAuthRoute({
|
|
route: nextRoute,
|
|
login,
|
|
password,
|
|
}),
|
|
...authRoutes,
|
|
]);
|
|
setRoute("");
|
|
setLogin("");
|
|
setPassword("");
|
|
routeRef.current?.focus();
|
|
};
|
|
|
|
const handleDeleteAuthRoute = (index: number) => {
|
|
const nextAuthRoutes = [...authRoutes];
|
|
nextAuthRoutes.splice(index, 1);
|
|
handleSave(nextAuthRoutes);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setRouteErrors([]);
|
|
setLoginErrors([]);
|
|
setPasswordErrors([]);
|
|
handleSave([]);
|
|
};
|
|
|
|
return (
|
|
<Grid gap={3} css={sectionSpacing}>
|
|
<Flex align="center" gap={1}>
|
|
<Text variant="titles">Authentication</Text>
|
|
{allowAuth === false && <ProChip>PRO</ProChip>}
|
|
<Tooltip
|
|
content={
|
|
<>
|
|
<Text>
|
|
Authentication asks visitors for HTTP Basic Auth credentials
|
|
before protected pages load on custom domains.
|
|
</Text>
|
|
<br />
|
|
<Text>
|
|
Routes use the same syntax as page paths, including :params and
|
|
* wildcards.
|
|
</Text>
|
|
{allowAuth === false && (
|
|
<>
|
|
<br />
|
|
<Text>
|
|
Authentication is a Pro feature. You can publish to staging
|
|
for free; upgrade to Pro to publish to custom domains.
|
|
</Text>
|
|
<Link
|
|
className={buttonStyle({ color: "gradient" })}
|
|
css={{ marginTop: theme.spacing[5], width: "100%" }}
|
|
color="contrast"
|
|
underline="none"
|
|
target="_blank"
|
|
href="https://webstudio.is/pricing"
|
|
>
|
|
Upgrade
|
|
</Link>
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
variant="wrapped"
|
|
>
|
|
<InfoCircleIcon
|
|
color={rawTheme.colors.foregroundSubtle}
|
|
tabIndex={-1}
|
|
/>
|
|
</Tooltip>
|
|
</Flex>
|
|
|
|
{parseErrors.length > 0 && (
|
|
<Grid gap={2}>
|
|
<Grid gap={1}>
|
|
{parseErrors.map((error) => (
|
|
<Text key={`${error.path}:${error.message}`} color="destructive">
|
|
{error.path}: {error.message}
|
|
</Text>
|
|
))}
|
|
</Grid>
|
|
<Flex>
|
|
<Button color="destructive" onClick={handleReset}>
|
|
Reset authentication
|
|
</Button>
|
|
</Flex>
|
|
</Grid>
|
|
)}
|
|
|
|
<Flex gap="2" justify="between">
|
|
<SearchField
|
|
placeholder="Search"
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
onAbort={() => setSearchQuery("")}
|
|
disabled={authRoutes.length === 0}
|
|
/>
|
|
<Button
|
|
color="ghost"
|
|
prefix={<TrashIcon />}
|
|
disabled={authRoutes.length === 0}
|
|
onClick={handleReset}
|
|
>
|
|
Delete all
|
|
</Button>
|
|
</Flex>
|
|
|
|
<Flex gap="2" align="center">
|
|
<InputErrorsTooltip
|
|
errors={routeErrors.length > 0 ? routeErrors : undefined}
|
|
side="top"
|
|
>
|
|
<Combobox<string>
|
|
inputRef={routeRef}
|
|
autoFocus
|
|
placeholder="/private or /docs/*"
|
|
value={route}
|
|
color={routeErrors.length === 0 ? undefined : "error"}
|
|
getItems={() => routeSuggestions}
|
|
itemToString={(item) => item ?? ""}
|
|
onItemSelect={(value) => handleRouteChange(value ?? "")}
|
|
onChange={(value) => {
|
|
if (value !== undefined) {
|
|
handleRouteChange(value);
|
|
}
|
|
}}
|
|
/>
|
|
</InputErrorsTooltip>
|
|
|
|
<InputErrorsTooltip
|
|
errors={loginErrors.length > 0 ? loginErrors : undefined}
|
|
side="top"
|
|
>
|
|
<InputField
|
|
placeholder="Login"
|
|
value={login}
|
|
color={loginErrors.length === 0 ? undefined : "error"}
|
|
onChange={(event) => {
|
|
setLogin(event.target.value);
|
|
setLoginErrors([]);
|
|
}}
|
|
/>
|
|
</InputErrorsTooltip>
|
|
|
|
<InputErrorsTooltip
|
|
errors={passwordErrors.length > 0 ? passwordErrors : undefined}
|
|
side="top"
|
|
>
|
|
<InputField
|
|
placeholder="Password"
|
|
type="password"
|
|
value={password}
|
|
color={passwordErrors.length === 0 ? undefined : "error"}
|
|
onChange={(event) => {
|
|
setPassword(event.target.value);
|
|
setPasswordErrors([]);
|
|
}}
|
|
/>
|
|
</InputErrorsTooltip>
|
|
|
|
<Button
|
|
disabled={
|
|
routeErrors.length > 0 ||
|
|
loginErrors.length > 0 ||
|
|
passwordErrors.length > 0
|
|
}
|
|
onClick={handleAddAuthRoute}
|
|
css={{ flexShrink: 0 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Flex>
|
|
|
|
{authRoutes.length > 0 ? (
|
|
<ScrollArea>
|
|
<Grid>
|
|
<List asChild>
|
|
<Flex direction="column" gap="1" align="stretch">
|
|
{filteredAuthRoutes.map((authRoute) => {
|
|
const index = authRoutes.indexOf(authRoute);
|
|
return (
|
|
<ListItem asChild key={authRoute.route}>
|
|
<Grid
|
|
align="center"
|
|
gap="2"
|
|
css={{
|
|
p: theme.spacing[3],
|
|
overflow: "hidden",
|
|
gridTemplateColumns: "1fr 1fr",
|
|
position: "relative",
|
|
"& > button": {
|
|
opacity: 0,
|
|
position: "absolute",
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
height: "auto",
|
|
borderRadius: 0,
|
|
background: theme.colors.backgroundPanel,
|
|
},
|
|
"&:hover > button, &:focus-within > button": {
|
|
opacity: 1,
|
|
},
|
|
}}
|
|
>
|
|
<Tooltip content={authRoute.route}>
|
|
<Text
|
|
truncate
|
|
css={{
|
|
wordBreak: "break-all",
|
|
}}
|
|
>
|
|
{authRoute.route}
|
|
</Text>
|
|
</Tooltip>
|
|
<Tooltip content={authRoute.auth.login}>
|
|
<Text truncate>{authRoute.auth.login}</Text>
|
|
</Tooltip>
|
|
<SmallIconButton
|
|
variant="destructive"
|
|
icon={<TrashIcon />}
|
|
aria-label={`Delete authentication for ${authRoute.route}`}
|
|
onClick={() => handleDeleteAuthRoute(index)}
|
|
/>
|
|
</Grid>
|
|
</ListItem>
|
|
);
|
|
})}
|
|
</Flex>
|
|
</List>
|
|
</Grid>
|
|
</ScrollArea>
|
|
) : null}
|
|
</Grid>
|
|
);
|
|
};
|