diff --git a/CLAUDE.md b/CLAUDE.md index 558cd8789..a8b84f703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Read [`docs/architecture.md`](docs/architecture.md) for the system overview, [`d - **Real-time co-editing:** Yjs CRDT engine. One Y doc per row (`page:`, `component:`, `layout:`) + one site-shell doc, multiplexed over `/admin/api/cms/site-socket`. The editor store stays the render source of truth: local mutations apply directly AND translate to Y ops (`@core/collab`); remote/undo changes project back. The server relay (`server/collab/`) persists continuously (blob + derived row JSON) — there is NO client-side save pipeline, no autosave, no Cmd+S. Undo is per-editor per-doc `Y.UndoManager`. Feature doc: [`docs/features/site-shell.md`](docs/features/site-shell.md) → "Real-time co-editing". - **Validation:** TypeBox at every untyped boundary. Schemas are source of truth (`type Foo = Static`, never a parallel `interface`). `zod` is banned repo-wide (the AI drivers pass TypeBox schemas through as JSON Schema, so no typebox→zod adapter is needed). Helpers + patterns: [`docs/reference/typebox-patterns.md`](docs/reference/typebox-patterns.md). - **Sanitization:** DOMPurify at the publisher boundary (`src/core/sanitize.ts`). -- **Plugins:** Zip packages with a `plugin.json` manifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a **QuickJS-WASM sandbox** — no Node/Bun ambient access, network gated by `network.outbound` permission + `networkAllowedHosts`. The VM bootstrap (SDK factory + `__run*` dispatchers) is authored as typed TS in `server/plugins/quickjs/bootstrap/src/` and bundled to committed string artifacts in `bootstrap/generated/` — after editing the source run `bun run bootstrap:sync` (gated by `plugin-bootstrap-fresh.test.ts`). Permission enforcement everywhere (VM, host, editor) validates against `grantedPermissions`, never the declared `permissions` array. Feature doc: [`docs/features/plugin-system.md`](docs/features/plugin-system.md). +- **Plugins:** Zip packages with a `plugin.json` manifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a **QuickJS-WASM sandbox** — no Node/Bun ambient access, network gated by `network.outbound` permission + `networkAllowedHosts`. The VM bootstrap (SDK factory + `__run*` dispatchers) is authored as typed TS in `server/plugins/quickjs/bootstrap/src/` and bundled to committed string artifacts in `bootstrap/generated/` — after editing the source run `bun run bootstrap:sync` (gated by `plugin-bootstrap-fresh.test.ts`). Permission enforcement everywhere (VM, host, editor) validates against `grantedPermissions`, never the declared `permissions` array. Feature doc: [`docs/features/plugin-system.md`](docs/features/plugin-system.md). **Site plugins** are authored in the site draft (`plugins//`, `SiteFileType: 'plugin'`), developed in the full-screen Plugin IDE (`/admin/plugins/develop/`, live co-editing over the collab socket), built server-side through the shared `@core/plugin-build` core with fail-closed import containment, and activated as ordinary `installed_plugins` rows (`source: 'site-local'`) — the runtime never branches on provenance. Doc: [`docs/features/site-plugins.md`](docs/features/site-plugins.md). - **Routing:** In-house router at `src/admin/lib/routing/`. Replaces `react-router-dom`. Use it for all internal admin navigation, including links rendered from the site editor. `react-router-dom` is banned, raw `` hard navigations are banned in admin UI, and `src/core/` + `src/modules/` must not import the admin router. Gated by `admin-router-usage.test.ts`. - **Icons:** `pixel-art-icons/icons/` — deep-imported, tree-shakeable. Vendored at `vendor/pixel-art-icons/`. No `lucide-react`, no inline SVG strings — gated by `no-third-party-icons.test.ts`, `direct-icon-imports.test.ts`. Add a new icon by importing it and running `bun run icons:sync`. - **AI providers:** No provider SDKs. Each driver in `server/ai/drivers/` talks directly to its provider's REST API over HTTP/SSE, sharing one multi-turn tool loop (`drivers/http/toolLoop.ts`). `@anthropic-ai/sdk`, `@anthropic-ai/claude-agent-sdk`, `@openai/agents`, and `@openrouter/agent` are banned repo-wide. The official split `@modelcontextprotocol/server` / `@modelcontextprotocol/client` v2 packages are **scoped, not banned**: allowed only under `server/ai/mcp/` (Instatic's MCP *server* implements a real wire protocol), still banned in the drivers and the browser. Gated by `ai-driver-isolation.test.ts`. diff --git a/bun.lock b/bun.lock index 5be0bc1ca..5527ec9b5 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "instatic", "dependencies": { "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", @@ -13,6 +14,8 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/lint": "^6.9.5", + "@codemirror/merge": "^6.12.2", + "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@dnd-kit/core": "^6.3.1", "@fontsource-variable/inter": "^5.2.8", @@ -33,7 +36,6 @@ "@tiptap/suggestion": "^3", "@use-gesture/react": "^10.3.1", "blurhash": "^2.0.5", - "codemirror": "^6.0.2", "dompurify": "^3.4.2", "esbuild": "^0.28.0", "fflate": "^0.8.2", @@ -54,6 +56,7 @@ "semver": "^7.7.4", "sharp": "^0.35.0", "uqr": "^0.1.3", + "y-codemirror.next": "^0.3.5", "y-protocols": "^1.0.7", "yjs": "^13.6.31", "zustand": "^5.0.12", @@ -174,6 +177,8 @@ "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + "@codemirror/merge": ["@codemirror/merge@6.12.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="], + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], @@ -868,8 +873,6 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1672,6 +1675,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y-codemirror.next": ["y-codemirror.next@0.3.6", "", { "dependencies": { "lib0": "^0.2.42" }, "peerDependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "yjs": "^13.5.6" } }, "sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 5074a000d..03f0b907d 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -329,6 +329,7 @@ AI-002 note: Data-scope default selection, save feedback, reload persistence, cl | PLUGIN-005 | P2 | partial | Plugins | Inspect and control plugin schedules | Active plugin with schedules | Plugin schedule dialog | Schedules list and mutating controls respect permissions | stale paused state, duplicate runs | | PLUGIN-006 | P2 | partial | Plugins | Install a plugin-provided site pack | Active plugin with pack | Plugins page | Pack content imports with clear feedback | conflicts, partial imports | | PLUGIN-008 | P2 | ✅ | Plugins | Upload invalid plugin package | Logged in | Plugins page | Error is specific and recoverable | generic failure, stuck upload | +| SITEPLUGIN-001 | P1 | ✅ | Plugins | Author, build and activate a site plugin in the Plugin IDE | Logged in with plugins.edit | Plugins page, Plugin IDE | Scaffold opens in the IDE, builds cleanly, activates through the permission review, and an edit reads as Draft changed | stale build served after an edit, activation without consent | PLUGIN-001 note: JSON manifest review/install step-up is automated in `capabilities.e2e.ts`; ZIP package review/install and activation are exercised by the packaged lifecycle/surfaces fixtures in `plugins.e2e.ts`; malformed ZIP/path-traversal/package-edge coverage remains lower-level or future browser expansion. diff --git a/docs/features/agent.md b/docs/features/agent.md index cc3c90be2..45b3b44df 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -104,7 +104,7 @@ src/admin/pages/site/agent/ src/admin/pages/content/agent/ ├── agentSliceConfig.content.ts — content-workspace config: scope, snapshot builder, executor wiring -├── contentAgentStore.ts — standalone per-mount Zustand store (AgentSlice only) +├── (store: src/admin/ai/createScopedAgentStore.ts — one AgentSlice-only Zustand instance per mount, shared with the Plugin IDE) ├── contentBridge.ts — content workspace write-tool executor ├── contentBridgeHandle.ts — live ContentPage operation handle └── useContentToolBridge.ts — always-mounted handle + content-scope MCP relay @@ -316,7 +316,7 @@ The handler (`server/ai/handlers/chat.ts`): 7. Calls `runChat(...)` with the full history as `req.messages`. Direct HTTP drivers have no server-side session, so each driver maps the whole `AiMessage[]` log into the provider's native message array every turn (the Anthropic driver pairs assistant `tool_use` blocks with their following `tool_result` turns). The runner pipes all stream events to the HTTP response. Before recording a terminal usage event, the runner flushes any pending assistant text so text-only replies have an assistant message row for per-turn usage and audit rollups. The multi-turn agentic loop lives in `drivers/http/toolLoop.ts`, not in a provider SDK. 8. Emits a terminal `ai.chat.completed` / `ai.chat.failed` audit event. -Valid scopes are `site`, `content`, `data`, and `plugin`; only `site` and `content` currently register tools and prompts. The handler rejects a request when the URL scope does not match the `ai_conversations.scope` row. +Valid scopes are `site`, `content`, `data`, and `plugin`; `site`, `content`, and `plugin` register tools and prompts, `data` has none yet. The handler rejects a request when the URL scope does not match the `ai_conversations.scope` row. ### `GET /admin/api/ai/audit?since=ISO&tz=IANA` @@ -520,7 +520,7 @@ When a node-targeting write tool (`site_insert_html`, `site_get_node_html`, `sit Content-scope tools are registered under `server/ai/tools/content/`. They use the same `POST /admin/api/ai/chat/content` stream and `POST /admin/api/ai/tool-result` bridge as the Site editor, but the snapshot and browser executor are content-specific: - `ContentAgentMount` builds a `ContentSnapshot` from the live Content workspace: visible `postType` collections, active collection id, active document fields/schema, and current user identity. -- `contentAgentStore.ts` mounts a standalone `AgentSlice` instance per `ContentPage` mount. The Content workspace is hook-based rather than a global Zustand store, so the bridge is exposed through `contentBridgeHandle.ts`. +- `ContentAgentMount` creates a standalone `AgentSlice` instance per `ContentPage` mount through `createScopedAgentStore` (`src/admin/ai/`, shared with the Plugin IDE). The Content workspace is hook-based rather than a global Zustand store, so the bridge is exposed through `contentBridgeHandle.ts`. - Server read tools hit the data, media, and user repositories through `ctx.db`; write tools are browser-bridged so unsaved draft state in `useContentEntryDraft` and the Tiptap body editor stay authoritative. **Server-side content reads — 7** @@ -634,7 +634,7 @@ export const siteAgentSliceConfig: AgentSliceConfig = { `getAgentStoreApi` reads the live store via `storeRef.ts`, wired in `store.ts` after store creation (`setAgentStoreApi(useEditorStore)`). This avoids a static import cycle: executor → store → agentSlice → executor. -The content workspace uses the same factory with `contentAgentSliceConfig` mounted in a standalone per-page store (`contentAgentStore.ts`). +The content workspace uses the same factory with `contentAgentSliceConfig` mounted in a standalone per-page store (`createScopedAgentStore`, shared with the Plugin IDE). `agentProviderUpdate.ts` owns the existing-conversation provider/model PUT and its failure reconciliation. A definite 4xx can roll the picker back to the re-read row; a timeout, network failure, or 5xx stays fail-closed unless the re-read already proves that the requested selection committed. `agentSlice.ts` keeps the ordering queue and Send lock because those coordinate store actions rather than HTTP persistence. @@ -864,7 +864,7 @@ unblocks deletion of the credential that had been protected by the default FK. - `src/admin/pages/site/agent/types.ts` — `ServerStreamEvent`, `AgentMessage`, `AgentRequestBody`, … - `src/admin/pages/site/agent/index.ts` — public barrel - `src/admin/pages/content/agent/ContentAgentMount.tsx` — content workspace AgentPanel mount + live bridge handle registration - - `src/admin/pages/content/agent/contentAgentStore.ts` — standalone content-workspace agent store + - `src/admin/ai/createScopedAgentStore.ts` — standalone per-mount agent store factory (Content workspace, Plugin IDE) - `src/admin/pages/content/agent/contentBridge.ts` — content write-tool browser dispatcher - `src/admin/pages/content/agent/contentBridgeHandle.ts` — imperative bridge handle registered by ContentPage - `src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx` — resolves model window/pricing/capabilities and places the meter in the action row diff --git a/docs/features/content-workspace.md b/docs/features/content-workspace.md index 9db04a222..0bae443ca 100644 --- a/docs/features/content-workspace.md +++ b/docs/features/content-workspace.md @@ -149,7 +149,7 @@ The mode switch is client-only. The markdown body is the source of truth in both The Content workspace has its own `content` chat scope, mounted as the `agent` panel in `ContentSidebar` when the current user has `ai.chat`. -`ContentAgentMount` creates a fresh per-page `AgentSlice` store (`contentAgentStore.ts`) for the visible chat panel. Independently, `ContentPage` always mounts `useContentToolBridge`, which registers a `ContentBridgeHandle` and the `content` MCP stream for as long as the workspace is open. The handle reads the current collections, selected entry, draft fields, schema, and current user via refs so either caller sees the same state the user sees. Tool writes go through that handle and then through `useContentWorkspace` / `useContentEntryDraft`, which keeps unsaved body/title/SEO/media changes and sidebar selection in sync. `content_set_active_document` loads an uncached row by id, switches across post-type collections without waiting for the target sidebar list, and commits the workspace and draft focus before it returns so an immediately following write targets the selected document. +`ContentAgentMount` creates a fresh per-page `AgentSlice` store (`createScopedAgentStore`) for the visible chat panel. Independently, `ContentPage` always mounts `useContentToolBridge`, which registers a `ContentBridgeHandle` and the `content` MCP stream for as long as the workspace is open. The handle reads the current collections, selected entry, draft fields, schema, and current user via refs so either caller sees the same state the user sees. Tool writes go through that handle and then through `useContentWorkspace` / `useContentEntryDraft`, which keeps unsaved body/title/SEO/media changes and sidebar selection in sync. `content_set_active_document` loads an uncached row by id, switches across post-type collections without waiting for the target sidebar list, and commits the workspace and draft focus before it returns so an immediately following write targets the selected document. The server registers 15 content-scope tools: diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 30febc736..48829d99b 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -122,7 +122,7 @@ server/ai/mcp/server.ts + registry.ts ▼ executeAiTool(...) / live editor bridge ├─ repositories and publisher for headless tools - └─ connection owner's open Site or Content workspace for browser tools + └─ connection owner's open Site, Content, or Plugin IDE workspace for browser tools ``` ### Module layout @@ -141,7 +141,7 @@ executeAiTool(...) / live editor bridge | `auth.ts` | Resolves OAuth access tokens or personal tokens to `{ connectorId, userId, capabilities }`; returns a discovery-aware 401 otherwise. | | `transports/http.ts` | Authenticated, Origin-validated `createMcpHandler` entry for MCP 2026-07-28 plus the stateless 2025 fallback. | | `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. | -| `editorBridge.ts` | Per-user, per-scope live workspace bridge. The stream carries an **idle lease** (120s, re-armed by every relayed tool request) so an active batch is never cut mid-flight; only quiet streams recycle. The workspace's reconnect loop (`useMcpWorkspaceBridge`) reopens a recycled healthy stream immediately off the stream-end network event — deliberately timer-free, because hidden webviews (backgrounded browser tabs) clamp timers to minutes while network events still fire — and a tab becoming visible short-circuits any pending retry delay. | +| `editorBridge.ts` | Per-user, per-scope (`site` / `content` / `plugin`) live workspace bridge. The stream carries an **idle lease** (120s, re-armed by every relayed tool request) so an active batch is never cut mid-flight; only quiet streams recycle. The workspace's reconnect loop (`useMcpWorkspaceBridge`) reopens a recycled healthy stream immediately off the stream-end network event — deliberately timer-free, because hidden webviews (backgrounded browser tabs) clamp timers to minutes while network events still fire — and a tab becoming visible short-circuits any pending retry delay. | | `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. | | `tools/uploadMediaTool.ts` | Server-resolved image upload (`media_upload`) — inline base64 or SSRF-guarded `sourceUrl` download, through the shared media pipeline. | @@ -153,7 +153,7 @@ Server-resolved tools work without an editor open. They include content reads, ` `media_upload` is the one server-resolved write that mutates outside the live editor draft: it adds an image to the Media library through the same `acceptUploadedMedia` core the HTTP route uses (magic-byte sniffing, SVG sanitisation, storage dispatch, responsive variants). Bytes arrive inline (base64) or via an https `sourceUrl` the host downloads under the plugin network layer's SSRF blocklist — https-only, DNS-resolved, per-redirect-hop re-validation, size-capped. It requires `ai.tools.write` plus `media.write`. -Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call. +Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site, Content, or Plugin IDE workspace; the plugin scope carries the IDE's file tools. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call. There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and overwrite the live document. Relayed edits need no post-tool save step: store mutations stream to the collab relay the moment they land, and every headless read (plus `site_publish`) flushes the relay server-side before it touches the DB — so a following read or publish always observes the edit. There is no client-side save flush, and no window in which the MCP caller can see stale data. diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index d07fa28f4..48ce2f9d4 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -2,7 +2,7 @@ End-to-end description of the plugin system: what plugins are, how they ship, how they run sandboxed, what they can do, and how to author them. -A plugin is a zip package containing a `plugin.json` manifest and one or more bundled JavaScript entrypoints. The SDK CLI usually authors that zip from `instatic-plugin.config.ts`, then the CMS host loads installed plugins at boot. Each server entrypoint runs in its own Bun `Worker`, and that worker hosts a **QuickJS-WASM sandbox** — no Node, no Bun, no host file system, no environment variables, no network unless explicitly granted. Plugins reach the CMS through SDK surfaces scoped to where they run: `api.plugin.*`, `api.cms.*`, `api.editor.*`, and `api.dashboard.*`. +A plugin is a package containing a `plugin.json` manifest and one or more bundled JavaScript entrypoints. Packages come from two sources — an uploaded **zip** (the SDK CLI usually authors it from `instatic-plugin.config.ts`) or a **site plugin** built from the site draft's `plugins//` source ([`site-plugins.md`](site-plugins.md)); both produce byte-compatible packages and identical runtime records, so everything below applies to both. The CMS host loads installed plugins at boot. Each server entrypoint runs in its own Bun `Worker`, and that worker hosts a **QuickJS-WASM sandbox** — no Node, no Bun, no host file system, no environment variables, no network unless explicitly granted. Plugins reach the CMS through SDK surfaces scoped to where they run: `api.plugin.*`, `api.cms.*`, `api.editor.*`, and `api.dashboard.*`. --- diff --git a/docs/features/site-plugins.md b/docs/features/site-plugins.md new file mode 100644 index 000000000..6c8f964ce --- /dev/null +++ b/docs/features/site-plugins.md @@ -0,0 +1,324 @@ +# Site plugins + +**TL;DR** — Site plugins are full backend/frontend plugins authored inside the +site draft (`plugins//`, `SiteFile` entries with `type: 'plugin'`), +developed in a full-screen **Plugin IDE** with live multi-author co-editing, +compiled server-side into packages **byte-compatible with installed plugins** +(`uploads/plugins/site.//`), and activated through the +exact same install/upgrade lifecycle, sandbox, permissions, settings, and +crash handling. The runtime never branches on provenance. + +This document is the design of record; the original spec and implementation +plan were working notes and are not kept in the repository. + +## The user-facing model + +Two concepts, deliberately: + +- **Frontend scripts** — the relabeled Scripts section of the site editor: + standalone page JavaScript with no backend counterpart. +- **Plugins** — units of code with powers, requiring operator approval. Some + are **installed** (uploaded zips), some are **site plugins** (authored in + this site's draft). Both share the permission review, settings surface, + lifecycle states, logs, and crash handling. + +If browser code talks to a plugin's server code, it lives in that plugin's +`frontend/` assets — the placement rule is surfaced in both UIs. + +## Where the code lives + +| Piece | Path | +| --- | --- | +| Source model (discovery, derivation, hash, templates, states) | `src/core/site-plugins/` | +| Shared package builder (CLI + server) | `src/core/plugin-build/` | +| Import containment resolver | `src/core/plugin-build/containment.ts` | +| Server build orchestrator (workspace, single-flight, timeout) | `server/plugins/sitePlugins/build.ts` | +| Workspace materializer | `server/plugins/sitePlugins/workspace.ts` | +| Revision retention / rollback target | `server/plugins/sitePlugins/retention.ts` | +| HTTP routes + service layer (list, activation engine) | `server/handlers/cms/sitePlugins/` | +| Disk-package activation seam (shared with zip installs) | `server/handlers/cms/plugins/install.ts` → `activatePluginPackageFromDisk` | +| Plugin IDE | `src/admin/pages/plugins/ide/` | +| Plugins-page integration (merged list, draft cards, scaffold dialog) | `src/admin/pages/plugins/PluginsPage.tsx`, `components/DraftSitePluginCard.tsx`, `components/NewSitePluginDialog.tsx` | +| Draft canvas preview (editor side) | `src/admin/pages/site/hooks/useDraftModulePackPreview.ts` | +| Collab CodeMirror binding | `src/admin/pages/site/code-editor/CollabCodeMirrorEditor.tsx` | +| Granular collab files (Y.Map + Y.Text content) | `src/core/collab/filesY.ts` | +| SDK route helper | `src/core/plugin-sdk/sitePluginRoute.ts` | +| AI plugin scope (tools, prompt, snapshot) | `server/ai/tools/plugin/`, `src/admin/pages/plugins/ide/agent/` | + +## Source layout and field ownership + +Source lives under `plugins//` (`` = one lowercase +kebab-case segment; runtime id `site.`). The draft `plugin.json` +carries **author intent only** — the build derives the rest and REJECTS +author-set derived fields (`additionalProperties: false` against +`SitePluginDraftManifestSchema`, whose field shapes are the runtime +manifest's own sub-schemas — `MANIFEST_AUTHOR_FIELD_SCHEMAS`). + +| Author writes | Builder derives | +| --- | --- | +| `name`, `description`, marketplace metadata | `id` (= `site.`) | +| `permissions` | `version` (= `1.0.+`) | +| `contentAccess[]`, `settings[]` | `apiVersion` (= host `PLUGIN_API_VERSION`) | +| `resources[]`, `adminPages[]` | `entrypoints` (folder convention: `server/index.ts`, `editor/index.ts`, `modules/*`) | +| `frontend.assets[]` (source paths, rewritten to built `.js`) | `assetBasePath` (pinned `/uploads/plugins/{id}/{version}`) | +| `networkAllowedHosts[]` | `pack` (from `pack/site.json` presence) | + +The generated version passes the manifest `SEMVERISH_PATTERN`, is monotonic +(ordered `migrate({ fromVersion })`), and carries the source fingerprint — +`contentHashOfVersion` powers the skip-rebuild and the `Draft changed` state. + +## Build pipeline + +`buildSitePlugin` (server): flush the collab relay (`runPublishFlush` — the +IDE persists continuously through the relay), read the persisted draft, +derive the manifest, materialize a temp workspace, and run the shared +`buildPluginPackage` with a **fail-closed `ImportResolverPolicy`**: + +- imports originating in the workspace must resolve inside it — upward + escapes, absolute paths, and import-attribute payloads + (`with { type: 'text' }`) fail the build (without this, draft code could + embed `.env`/DB files into a bundle and exfiltrate them through the + plugin's own routes — the sandbox literal scan does not cover this class); +- **build-time macros are refused before Bun parses the file.** Bun runs + `import x from './m.ts' with { type: 'macro' }` inside the host process at + bundle time, with full Node/Bun access; the macro module resolves inside + the workspace, so path containment cannot catch it, and the sandbox scan + runs on the output after the macro already executed. The containment + plugin's `onLoad` hook (`assertNoBuildTimeMacros`) scans every workspace + source textually and fails closed: only `{ type: 'json' | 'text' | 'file' + | 'toml' }` attribute clauses are allowed, anything else (a macro, a + comment inside the clause, an escaped key) is a build error; +- exactly one bare specifier is mapped: `@instatic/plugin-sdk` → the host + SDK entry (pure data builders inline into sandbox bundles); +- editor/admin bundles keep the host-runtime externals (import-map + resolved). + +Builds are single-flight per plugin with a 30 s timeout, and every +lifecycle transition (activate, rollback, delete) is serialized per plugin +id on top of that (`withSitePluginLock` in the service layer) — two +overlapping activations would otherwise both read the same row version, +derive the same next counter, and race the upgrade path. The diagnostics +strip, the preview-pack route and the AI `plugin_validate` tool all run the +same `buildDraftSitePlugin` (service layer). Diagnostics carry the author's +file, line and column (`plugins//modules/foo.ts:22:16: …`); the modules +pack's generated facade never appears in them. Validate-only mode +writes into the throwaway workspace (never uploads), returns diagnostics, +and can return the built modules bundle text (the preview-pack route). +Diagnostics are bundle/parse errors, sandbox-scan violations, containment +violations, and manifest errors — **not** TypeScript semantic errors +(`Bun.build` does not typecheck; the UI must not promise it). + +## Runtime model and lifecycle + +A site plugin IS an `installed_plugins` row (`source: 'site-local'`, +migration `031_installed_plugins_source`, additive, both dialects). The +worker, QuickJS VM, route registry, hooks, schedules, settings/secrets, +event broadcaster, crash recovery, and frontend injection consume it with +**zero provenance branches** (gated by `site-plugin-invariants.test.ts`). + +Activation rides `activatePluginPackageFromDisk` — the same seam the zip +route uses: fresh = `install` → `activate`; version change = old +`deactivate` → row swap → `migrate({ fromVersion })` → `activate` with +rollback on failure. Settings and encrypted secrets survive rebuilds +(insert-if-absent seeding) — a contract, not an accident. Declared packs +auto-sync on activation like installed plugins. + +### Authority + +- **Authoring** (IDE editing, scaffold) — `plugins.edit`, its own + capability: the scaffold endpoint requires it, and the relay write + policy's `plugins` category gates every live edit to a `type: 'plugin'` + file (add/rename/delete/content — even full site-writers need it). + Never `plugins.install`, never a `site.*` capability. +- **Validation / preview** — no elevated capability (`site.read`). +- **`Build & activate` / rollback / delete** — `plugins.install`; step-up + ONLY on the consent moments: first activation, grant-set changes, and + rollbacks that change the grant set. Same-grant rebuilds skip both the + review and the step-up — the security boundary is the grant set, not the + code revision. +- Grants = declared, in both directions: activation grants exactly what the + draft declares; dropping a permission shrinks the grant. +- The `site.*` id namespace is **reserved**: the zip boundary + (`readPluginPackage`) rejects uploaded packages claiming it. + +### Runtime states + +Computed by `computeSitePluginState` (shared server + UI vocabulary) over +the **union** of draft folders and site-local rows — deleting the folder +never hides a running backend (`Source missing` still lists, offering +deactivate/delete): + +`active` · `draft-changed` · `build-failed` · `runtime-error` · `disabled` +· `source-missing` + +A changed grant set is not a state of its own: grants live in +`plugin.json`, so any grant change is a draft change, and the consent step +(the permission review dialog, then step-up) belongs to the `Build & +activate` click. The summary still carries `newPermissions` / +`removedPermissions` so the dialog can show the diff. + +Each state maps to ONE smart primary action (`sitePluginPrimaryAction`), +rendered in the IDE header; the Plugins-page draft card only offers `Open +IDE` (an activated site plugin is an ordinary installed row there). +Unavailable actions are disabled with an inline reason, never hidden. + +## The Plugin IDE + +`/admin/plugins/develop/` — a full-screen workspace-canvas route +(`AdminWorkspace: 'pluginIde'`, layout persistence like every canvas): +file tree (left, resizable) and the co-edited CodeMirror buffer with the +diagnostics strip beneath it. The tree is built from the same primitives as +the Layers panel (`@site/ui/Tree`): folders sort first, a folder's chevron +sits at the end of its row so files and folders at one depth share a left +edge, and a clicked, expanded folder paints its subtree as one `TreeGroup` +surface — the same nesting the Layers panel gives a selected container. +There is deliberately no right panel: +`plugin.json` is edited as raw JSON in the buffer (auto-selected on open), +and every manifest mistake surfaces as a named diagnostic. + +**Live multi-author co-editing**: the shell's `files` key is a granular +Y.Map — one entry per file id, `content` as Y.Text (`@core/collab/filesY.ts`). +The IDE binds ONLY `site:default` over the site socket (its own +`CollabProvider`; server-seeded docs), so: + +- two admins co-type one file character-level, with per-peer colored carets + (y-codemirror.next); +- file CRUD and renames merge per-field; +- the shell's `files` value is granular for every consumer of the site doc + (the site editor's Scripts panel still writes whole strings at the store + layer, so it is the IDE that co-edits character-level); +- presence is shared: site editors see IDE users in their roster; IDE rows + show who's editing which file; +- undo is per-file and local-only: the co-edited buffer mounts WITHOUT + CodeMirror's own `history()` (which would record peers' deltas as + undoable steps) and the Y.UndoManager keymap takes precedence over every + other Mod-z binding. + +Two lifecycle rules keep the session safe: + +- **nothing writes before the first sync.** An unsynced doc has no `files` + map; creating one client-side would win the merge (the server seeds with + client id 1) and replace every site file with the one just created. Every + mutating session method throws `IdeNotSyncedError` until `synced()` is + true, the file tree keeps New file / rename / delete disabled with the + reason, and the agent bridge answers "still connecting"; +- **a relay reset rebinds.** `FRAME_RESET` (an out-of-relay shell write — + scaffold, delete, settings save, import — reseeded the doc) destroys the + bound Y.Doc. The session rebinds at once, bumps its `generation`, drops + the undo managers that referenced the dead Y.Text, and the buffer + remounts keyed on the generation instead of typing into a destroyed type. + +There is no save button — the relay persists continuously; Cmd+S re-runs +diagnostics. Automatic validation runs debounced on every change. + +### The AI panel (`plugin` chat scope) + +The left rail's AI button (gated by `ai.chat`) docks the shared AgentPanel +— the same chat Site and Content have, on the `plugin` tool scope +(`server/ai/tools/plugin/`): + +- **File tools are browser-bridged** to the live CRDT session: the agent + reads exactly what you see (un-persisted keystrokes included) and its + edits merge character-level with concurrent typing. + `plugin_read_file` paginates + hashes; `plugin_patch_file` requires the + latest hash (stale edits fail instead of clobbering); write/rename/ + delete gate on `plugins.edit`; `plugin_open_file` moves the visible + buffer. +- **`plugin_docs`** serves the curated author reference (manifest, + admin-pages, server, modules, editor, frontend, workflow, examples) so + the agent reads contracts instead of guessing them from build errors. + Content lives in `server/ai/tools/plugin/docs.ts` and must track the SDK + contracts it documents. +- **Lifecycle tools are server-resolved**: `plugin_list_plugins`, + `plugin_validate` (the diagnostics-strip build), and `plugin_activate` — + same-grant rebuilds only. A changed grant set is refused with an + instruction to confirm in the IDE header: the permission review + + step-up stays a human consent moment. +- The IDE registers its tool bridge for the whole page mount + (`usePluginIdeToolBridge`), so **external MCP connectors** reach the + open IDE with the panel closed — full parity with Site/Content + (docs/features/mcp-connectors.md). Lifecycle tools also run headless + over MCP. + +The per-request snapshot (open plugin, file list, active buffer, runtime +state, latest diagnostics) rides every send; the `plugin` scope has its own +default model row on /admin/ai/defaults. + +`plugin.json` has no structured editor — the raw buffer is the manifest +surface. Manifest coherence (e.g. an `editor/` entry file without +`editor.code` declared) is enforced by validation, and the human-readable +permission treatment lives where it matters: the activation review dialog. + +## Publish coupling, retention, preview + +- Activating a revision with visitor-facing surfaces (frontend assets or a + module pack) **republishes before sweeping** — baked Layer-A HTML embeds + versioned asset URLs and must never reference a deleted revision. Both + artefact kinds are re-baked in place under the publish lock: pages + (`republishAllPages`) and entry-template data rows such as `/posts/hello` + (`republishAllDataRows`), stamped with the version that becomes current + at the bump that follows. Backend-only plugins skip the republish. +- Retention keeps the five highest builds plus the active one + (`RETAINED_REVISIONS` in `server/plugins/sitePlugins/retention.ts`); the + sweep runs after activation and the coupled republish succeed. Every + retained build is a rollback target: the summary lists them + (`revisions`, newest first, with the build time), the IDE's `Roll back + to…` submenu offers them, and `POST …/rollback { version }` re-activates + one — the version is validated against the retained directories, never + joined into a path unchecked, and a target with a different grant set + steps up like any grant change. Source rolls forward only, so the + artifact is the only rollback; after one the draft reads `Draft changed` + and `Build & activate` redeploys the newest code. Uninstall sweeps the + whole `uploads/plugins/site./` tree via the existing teardown. +- `Preview in canvas` (module drafts): the IDE opens + `/admin/site?previewSitePlugin=`; the editor fetches the + validate-only bundle from `GET .../preview-pack.js` (no-store), activates + it browser-side into the requesting session only, and badges the modules + `Draft` in the inserter. Nothing registers server-side. Publishing a page + that uses a `site.*` module with no active registration logs a publish + warning naming the plugin (renderNode). + +## Export / import + +The site bundle carries plugin SOURCE (`type: 'plugin'` shell files) — +never generated artifacts, secrets, or runtime rows. On import the source +lands as draft; the operator rebuilds and activates on the target so +grants, secrets, and network allowlists are reviewed in that environment. + +## Frontend → backend calls + +```ts +import { sitePluginRoute } from '@instatic/plugin-sdk' +await fetch(sitePluginRoute('newsletter', '/subscribe'), { method: 'POST' }) +// → /admin/api/cms/plugins/site.newsletter/runtime/subscribe +``` + +Pure string helper: frontend bundles inline it (published pages have no +import map); editor/admin bundles resolve it through the host import map. + +## Forbidden patterns + +- Author-set derived fields in a draft `plugin.json` (fails the build). +- `site.*` ids in uploaded zip packages (rejected at the zip boundary). +- `type === 'plugin'` matches in any published-output pipeline (gated). +- Provenance branches in runtime machinery (gated). +- Calling `buildPluginPackage` server-side without the containment policy + (gated). +- `with { type: 'macro' }` (or any non-inert import attribute) in draft + source — refused before Bun parses the file (gated). +- Writing to the IDE session before `synced()` is true. + +## Gate tests + +- `src/__tests__/architecture/site-plugin-invariants.test.ts` +- `src/__tests__/architecture/site-plugin-file-isolation.test.ts` +- `src/__tests__/server/pluginPackageNamespace.test.ts` +- `src/__tests__/server/pluginSourceColumn.test.ts` +- `src/__tests__/plugins/pluginBuildContainment.test.ts` +- `src/__tests__/server/sitePluginBuild.test.ts` +- `src/__tests__/server/sitePluginLifecycle.test.ts` +- `src/__tests__/server/sitePluginRetention.test.ts` +- `src/__tests__/server/sitePluginPreviewPack.test.ts` +- `src/__tests__/server/sitePluginExport.test.ts` +- `src/__tests__/sitePlugins/*` (source model, route helper) +- `src/__tests__/collab/filesGranular.test.ts` (co-editing granularity) diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index b61f10560..d89946ff6 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -185,7 +185,7 @@ Arbitrary files attached to the site: CSS stylesheets, TypeScript scripts, React type SiteFile = { id: string // nanoid-generated; stable (path is mutable on rename) path: string // POSIX-style path relative to site root, e.g. 'src/styles/main.css' - type: SiteFileType // 'component' | 'script' | 'style' | 'asset' | 'config' | 'doc' + type: SiteFileType // 'component' | 'script' | 'style' | 'asset' | 'config' | 'doc' | 'plugin' content?: string // text content; absent for 'asset' files blob?: { mimeType: string; base64: string } // binary payload for 'asset' only generated?: boolean // auto-generated by scaffold; hidden until ejected @@ -201,6 +201,7 @@ Schema source of truth: `src/core/files/schemas.ts`. - `'script'` files are exposed to module render functions through `props._siteScripts`. - `'component'`, `'config'`, and `'doc'` files are stored but not auto-emitted; modules can read them via `ctx.siteFiles`. - `'asset'` files store binary content in `blob` (base64-encoded); the file's `content` field is absent. +- `'plugin'` files are site plugin source (`plugins//**`). They NEVER enter published bundles, `props._siteScripts`, or module-readable lists, and never appear in the site editor — they are edited exclusively in the Plugin IDE. See [`site-plugins.md`](site-plugins.md); gated by `site-plugin-file-isolation.test.ts`. Generated files (e.g. `package.json`, `vite.config.ts`) are hidden in the Site Explorer until the user ejects them. Files are created and renamed through the Site Explorer panel and edited with the CodeMirror-backed code editor. @@ -512,8 +513,9 @@ plus one `site:` doc per branch for the shell and the roster order as a Y.Map with the module's inline-text prop as Y.Text, nested `breakpointOverrides` Y.Maps, `children` as Y.Array; `parentId` is derived, never stored). Layout snapshots are whole-value LWW. The shell keeps -`settings` / `styleRules` / `explorer` as per-entry Y.Maps and everything -else plain. Deterministic reconciles (`integrity.ts` tree repair, roster +`settings` / `styleRules` / `explorer` as per-entry Y.Maps, `files` as a +per-file Y.Map whose `content` is a Y.Text (`filesY.ts`, so code files +co-edit character-level), and everything else plain. Deterministic reconciles (`integrity.ts` tree repair, roster order) run identically on every peer. **Editor write path** (`src/admin/pages/site/store/slices/site/collabBinding.ts`): diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 24d986128..9b13d785a 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -104,12 +104,13 @@ Was a single `runtime.manage`. Split because adapter election (bytes go to a plu ### Plugins (granular split) -Was a single `plugins.manage`. Split per the four very different blast radii: read / configure / install (RCE-class) / lifecycle. +Was a single `plugins.manage`. Split per the very different blast radii: read / configure / edit (authoring) / install (RCE-class) / lifecycle. | Capability | Grants | Step-up | Roles | |------------------------|---------------------------------------------------------------------|---------|---------------| | `plugins.read` | List installed plugins; read masked settings; view event SSE stream; read schedule list. Also gates `/dashboard/plugins`. | no | Owner, Admin | | `plugins.configure` | Edit per-plugin settings via `PUT /plugins/:id/settings`; manage plugin records via `/plugins/:id/resources/*`. | yes (settings only) | Owner, Admin | +| `plugins.edit` | Author site-plugin source: scaffold via `POST /site-plugins`, create/edit/rename/delete `plugins//**` draft files (Plugin IDE, collab relay `plugins` write category, HTTP save). Authoring only — code runs nothing until a `plugins.install` activation. | no | Owner, Admin | | `plugins.install` | Install / upgrade / uninstall plugins; pack install; inspect-package. **RCE-class — runs third-party code on the host.** | yes (mutations) | Owner, Admin | | `plugins.lifecycle` | Enable / disable / restart plugins; schedule run-now / pause / resume. | yes (mutations) | Owner, Admin | diff --git a/package.json b/package.json index 422a42268..2ec39cbff 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", @@ -68,6 +69,8 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/lint": "^6.9.5", + "@codemirror/merge": "^6.12.2", + "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@dnd-kit/core": "^6.3.1", "@fontsource-variable/inter": "^5.2.8", @@ -88,7 +91,6 @@ "@tiptap/suggestion": "^3", "@use-gesture/react": "^10.3.1", "blurhash": "^2.0.5", - "codemirror": "^6.0.2", "dompurify": "^3.4.2", "esbuild": "^0.28.0", "fflate": "^0.8.2", @@ -109,6 +111,7 @@ "semver": "^7.7.4", "sharp": "^0.35.0", "uqr": "^0.1.3", + "y-codemirror.next": "^0.3.5", "y-protocols": "^1.0.7", "yjs": "^13.6.31", "zustand": "^5.0.12", diff --git a/server/ai/drivers/types.ts b/server/ai/drivers/types.ts index 5f315f45d..69c3681d3 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -157,6 +157,8 @@ export interface ToolContextBase { * onSnapshot) so later server read tools see post-mutation state. */ snapshot: unknown + /** The server's uploads root — see `ToolContext.uploadsDir`. */ + readonly uploadsDir: string | null } // --------------------------------------------------------------------------- diff --git a/server/ai/handlers/chat.ts b/server/ai/handlers/chat.ts index 286d0011a..2e1cbcc4f 100644 --- a/server/ai/handlers/chat.ts +++ b/server/ai/handlers/chat.ts @@ -70,6 +70,11 @@ import { buildContentSystemPrompt, type ContentSnapshot, } from '../tools/content' +import { + buildPluginSystemPrompt, + emptyPluginIdeSnapshot, + PluginIdeSnapshotSchema, +} from '../tools/plugin' import { createBridge, createConversationsPersister, @@ -95,17 +100,19 @@ export function tryHandleAiChat( req: Request, db: DbClient, pathname: string, + uploadsDir: string | null, ): Promise | null { if (!pathname.startsWith('/admin/api/ai/chat/')) return null const scope = pathname.slice('/admin/api/ai/chat/'.length) if (!VALID_SCOPES.includes(scope as ToolScope)) return null - return handleAiChat(req, db, scope as ToolScope) + return handleAiChat(req, db, scope as ToolScope, uploadsDir) } async function handleAiChat( req: Request, db: DbClient, scope: ToolScope, + uploadsDir: string | null, ): Promise { if (req.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) @@ -378,6 +385,7 @@ async function handleAiChat( scope, conversationId: conversation.id, snapshot, + uploadsDir, } const { bridgeId, bridge, destroy } = createBridge( emit, @@ -517,6 +525,17 @@ export function buildSystemPromptForScope( if (scope === 'content') { return buildContentSystemPrompt((snapshot ?? emptyContentSnapshot()) as ContentSnapshot) } + if (scope === 'plugin') { + if (snapshot === undefined || snapshot === null) { + return buildPluginSystemPrompt(emptyPluginIdeSnapshot()) + } + const result = safeParseValue(PluginIdeSnapshotSchema, snapshot) + if (!result.ok) { + console.error('[ai/chat] invalid plugin snapshot, using empty fallback:', result.errors) + return buildPluginSystemPrompt(emptyPluginIdeSnapshot()) + } + return buildPluginSystemPrompt(result.value) + } // Other scopes don't have system prompts yet. The driver gets a minimal // prompt so the conversation isn't completely contextless. return [ diff --git a/server/ai/handlers/index.ts b/server/ai/handlers/index.ts index 51a28205a..b45b890f5 100644 --- a/server/ai/handlers/index.ts +++ b/server/ai/handlers/index.ts @@ -25,6 +25,7 @@ export function tryHandleAi( req: Request, db: DbClient, url: URL, + options: { uploadsDir?: string } = {}, ): Promise | null { const pathname = url.pathname if (!pathname.startsWith('/admin/api/ai/')) return null @@ -44,7 +45,7 @@ export function tryHandleAi( tryHandleAiMcpManagement(req, db, pathname) ?? tryHandleAiEditorBridge(req, db, pathname) ?? tryHandleAiAudit(req, db, url, pathname) ?? - tryHandleAiChat(req, db, pathname) ?? + tryHandleAiChat(req, db, pathname, options.uploadsDir ?? null) ?? tryHandleAiToolResult(req, db, pathname) ?? tryHandleAiCredentials(req, db, pathname) ?? tryHandleAiConversations(req, db, url, pathname) ?? diff --git a/server/ai/mcp/editorBridge.ts b/server/ai/mcp/editorBridge.ts index 58a71f9c5..01c9581e0 100644 --- a/server/ai/mcp/editorBridge.ts +++ b/server/ai/mcp/editorBridge.ts @@ -31,7 +31,7 @@ interface EditorBridgeEntry { destroy: () => void } -export type EditorBridgeScope = 'site' | 'content' +export type EditorBridgeScope = 'site' | 'content' | 'plugin' /** * How long a stream may sit with NO tool traffic before the server drops it. * This is an IDLE lease: every relayed tool request re-arms it, so an active diff --git a/server/ai/mcp/handlers/editorBridge.ts b/server/ai/mcp/handlers/editorBridge.ts index d2fd49826..a61724139 100644 --- a/server/ai/mcp/handlers/editorBridge.ts +++ b/server/ai/mcp/handlers/editorBridge.ts @@ -29,6 +29,7 @@ const PATH = '/admin/api/ai/editor-bridge' const EditorBridgeScopeSchema = Type.Union([ Type.Literal('site'), Type.Literal('content'), + Type.Literal('plugin'), ]) // Mirrors the Content workspace entry gate in `src/admin/access.ts` and @@ -65,7 +66,7 @@ async function handle(req: Request, db: DbClient): Promise { ) if (!scopeResult.ok) { return jsonResponse( - { error: 'Query parameter `scope` is required (one of: site, content)' }, + { error: 'Query parameter `scope` is required (one of: site, content, plugin)' }, { status: 400 }, ) } @@ -73,9 +74,13 @@ async function handle(req: Request, db: DbClient): Promise { // Hosting a bridge requires access to the workspace whose live state the // browser tool will read or mutate. - const hasWorkspaceAccess = scope === 'site' - ? userHasCapability(userOrResponse, 'site.read') - : userHasAnyCapability(userOrResponse, CONTENT_BRIDGE_CAPABILITIES) + const hasWorkspaceAccess = + scope === 'site' || scope === 'plugin' + // Plugin IDE entry mirrors its page gate: site.read opens the IDE + // read-only; write tools re-check plugins.edit in the browser bridge + // and at tool selection. + ? userHasCapability(userOrResponse, 'site.read') + : userHasAnyCapability(userOrResponse, CONTENT_BRIDGE_CAPABILITIES) if (!hasWorkspaceAccess) { return jsonResponse({ error: 'Forbidden' }, { status: 403 }) } diff --git a/server/ai/mcp/registry.ts b/server/ai/mcp/registry.ts index 3f8a011d0..8fb1381d2 100644 --- a/server/ai/mcp/registry.ts +++ b/server/ai/mcp/registry.ts @@ -27,6 +27,7 @@ import type { AiTool } from '../runtime/types' import { toolAllowedForCapabilities } from '../tools/capabilityGate' import { contentTools } from '../tools/content' import { siteTools } from '../tools/site' +import { pluginTools } from '../tools/plugin' import { styleMcpTools } from './tools/styleTools' import { contextMcpTools } from './tools/contextTool' import { documentMcpTools } from './tools/documentTools' @@ -56,6 +57,10 @@ function allMcpTools(runtime?: McpPublishRuntime): AiTool[] { uploadMediaMcpTool, ...contentTools, ...siteTools, + // Plugin scope: file tools relay to the connector owner's open Plugin + // IDE; plugin_list_plugins / plugin_validate / plugin_activate run + // headless (server-resolved) — the site_publish precedent. + ...pluginTools, ] const byName = new Map() for (const tool of ordered) { diff --git a/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index 52dd309f3..55aabca77 100644 --- a/server/ai/mcp/server.ts +++ b/server/ai/mcp/server.ts @@ -43,6 +43,7 @@ const NOOP_BRIDGE: AiBrowserBridge = { const NO_WORKSPACE_MESSAGE: Record = { site: 'This tool runs in the Instatic Site editor. Open the Site editor in a browser (signed in as the connector owner) and try again.', content: 'This tool runs in the Instatic Content workspace. Open the Content workspace in a browser (signed in as the connector owner) and try again.', + plugin: 'This tool runs in the Instatic Plugin IDE. Open /admin/plugins/develop/ in a browser (signed in as the connector owner) and try again.', } /** @@ -59,6 +60,7 @@ const NO_WORKSPACE_MESSAGE: Record = { const BROWSER_WORKSPACE_REQUIREMENT: Record = { site: 'Requires the Instatic Site editor to be open in a browser, signed in as the connector owner; this tool edits that live workspace and cannot run headlessly.', content: 'Requires the Instatic Content workspace to be open in a browser, signed in as the connector owner; this tool edits that live workspace and cannot run headlessly.', + plugin: 'Requires the Instatic Plugin IDE to be open in a browser on the plugin being edited, signed in as the connector owner; this tool edits that live workspace and cannot run headlessly.', } /** Tool description as advertised over MCP — browser tools carry their precondition. */ @@ -149,7 +151,7 @@ export function buildMcpServer(ctx: McpServerContext): Server { // Content; keep that invariant explicit instead of guessing a bridge. let bridge = NOOP_BRIDGE if (tool.execution === 'browser') { - if (tool.scope !== 'site' && tool.scope !== 'content') { + if (tool.scope !== 'site' && tool.scope !== 'content' && tool.scope !== 'plugin') { return project({ isError: true, content: [{ type: 'text', text: `Browser tool "${tool.name}" has unsupported scope "${tool.scope}".` }], @@ -201,6 +203,7 @@ export function buildMcpServer(ctx: McpServerContext): Server { scope: tool.scope === 'shared' ? 'content' : tool.scope, conversationId: `mcp:${ctx.connectorId}`, snapshot: null, + uploadsDir: ctx.uploadsDir ?? null, }) } catch (err) { // Browser bridge rejection is terminal for a chat turn, but MCP has no diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index afdc8f51b..baa030efa 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -146,6 +146,12 @@ export interface ToolContext { readonly scope: ToolScope readonly conversationId: string readonly snapshot: unknown + /** + * The server's uploads root, for tools that build/activate on-disk plugin + * revisions (`plugin_activate`). Null when the transport didn't thread it + * (tests, misconfigured boot) — such tools fail with a clear error. + */ + readonly uploadsDir: string | null readonly signal: AbortSignal } diff --git a/server/ai/tools/index.ts b/server/ai/tools/index.ts index 1c00c6077..0c29bedb0 100644 --- a/server/ai/tools/index.ts +++ b/server/ai/tools/index.ts @@ -1,8 +1,8 @@ /** * Tool registry root — selects the right toolset for a chat scope. * - * `site` and `content` scopes have tools registered. `data` and `plugin` - * are reserved scopes with no toolset yet. + * `site`, `content`, and `plugin` scopes have tools registered. `data` is a + * reserved scope with no toolset yet. * * Adding a new scope: * 1. Create `server/ai/tools//` with its tool files + index.ts. @@ -25,6 +25,7 @@ import { toolAllowedForCapabilities } from './capabilityGate' import type { AiTool, ToolScope } from './types' import { siteTools } from './site' import { contentTools } from './content' +import { pluginTools } from './plugin' function scopeToolset(scope: ToolScope): AiTool[] { switch (scope) { @@ -36,8 +37,7 @@ function scopeToolset(scope: ToolScope): AiTool[] { // Reserved: no data-scope toolset yet. return [] case 'plugin': - // Reserved: no plugin-scope toolset yet. - return [] + return pluginTools } } diff --git a/server/ai/tools/plugin/docs.ts b/server/ai/tools/plugin/docs.ts new file mode 100644 index 000000000..2756106b1 --- /dev/null +++ b/server/ai/tools/plugin/docs.ts @@ -0,0 +1,354 @@ +/** + * Plugin-scope documentation tool — `plugin_docs`. + * + * The system prompt carries only a summary; this tool is the authoritative + * on-demand reference so the agent reads the REAL contract before writing + * code instead of discovering it through build/runtime errors. Content is + * curated by hand from the SDK types and host loaders it documents + * (`src/core/plugin-sdk/types/*`, `src/core/plugins/adminRuntime.ts`, + * `src/core/site-plugins/templates.ts`) — when those contracts change, + * update the matching topic here in the same change. + * + * Deliberately embedded strings (not repo-file reads): they version with + * the build, exist on every install, and stay author-facing rather than + * contributor-facing. + */ +import { Type } from '@core/utils/typeboxHelpers' +import { aiToolError, aiToolOk } from '@core/ai' +import type { AiTool } from '../types' + +const TOPICS: Record = { + manifest: { + summary: 'plugin.json fields, permissions list, derived fields, coherence rules', + content: `# plugin.json (site plugin draft manifest) + +Author-owned fields — everything else is DERIVED by the build and must NOT be set +(id, version, apiVersion, entrypoints, assetBasePath, pack, grantedPermissions, icon): + +{ + "name": "…", // required + "description": "…", + "permissions": ["…"], // see list below + "networkAllowedHosts": ["api.example.com"], // required with network.outbound + "resources": [ … ], // plugin-owned record types (see admin-pages topic) + "adminPages": [ … ], // admin navigation pages (see admin-pages topic) + "settings": [ … ] // operator-editable settings (text/textarea/number/select/toggle variants) +} + +Entrypoints derive from folders: server/index.ts → sandboxed backend; +editor/index.ts → unsandboxed admin code (requires "editor.code"); +modules/*.ts → canvas module pack (requires "modules.register"); +frontend/* → built/published assets. + +Permissions (exact ids): +- admin.navigation — adminPages appear in the admin nav +- cms.storage — records via api.cms.storage.collection() +- cms.routes — register backend HTTP routes; cms.routes.public additionally allows anonymous routes +- cms.hooks — subscribe to host events +- cms.content.read / cms.content.write / cms.content.publish / cms.content.delete — + the host's content tables via api.cms.content.*; ALSO requires the manifest to + list target tables in "contentAccess": [{ "table": "posts", "modes": ["read"] }] +- cms.content.tables.manage — create user tables (dangerous) +- editor.code — plugin JS loaded into the admin window (required by editor/ entry AND kind:'app' admin pages) +- editor.toolbar / editor.commands / editor.canvas / editor.panels / + editor.store.read / editor.store.write — specific editor API surfaces +- modules.register / loops.register / visualComponents.register / dashboard.widgets.register +- media.storage.adapter / media.url.transform / media.variant.delegate +- frontend.assets — inject declared tags into published pages (manifest "frontend": { "assets": [...] }) +- network.outbound — fetch() from the sandbox, only to networkAllowedHosts +- cms.schedule — scheduled jobs via api.cms.schedule + +Coherence is enforced at build: an entry file whose permission is missing fails +validation with a named diagnostic. Grants = declared: activation grants exactly +the manifest's permission list (changes need human consent in the IDE header).`, + }, + 'admin-pages': { + summary: 'adminPages content kinds — markdown, resource CRUD, map, app (React)', + content: `# Admin pages + +"adminPages": [{ "id": "kebab-id", "title": "…", "navLabel": "…", "content": … }] +Requires the "admin.navigation" permission. content is a strict union: + +1. Markdown (static, no code): + { "kind": "markdown", "body": "…markdown…", "heading": "optional" } + +2. Resource CRUD page — THE way to build "manage records" pages (customers, + leads, bookings). The HOST renders the full list/create/delete UI; records + live in plugin storage; zero custom code: + { "kind": "resource", "heading": "Customers", "resource": "customers" } + plus a matching top-level declaration: + "resources": [{ + "slug": "customers", + "labelField": "name", + "fields": [ + { "id": "name", "label": "Name", "type": "text", "required": true }, + { "id": "email", "label": "Email", "type": "text" }, + { "id": "active", "label": "Active", "type": "boolean" } + ] + }] + Server code reads/writes the same records: api.cms.storage.collection('customers') + (requires "cms.storage" for server access; the page itself needs no extra code). + +3. Map page: { "kind": "map", "heading": "…", "pins": [{ "label", "x", "y" }] } + +4. App page — custom React UI (heavyweight; needs "editor.code"): + { "kind": "app", "heading": "…", "entry": "frontend/customers.js" } + - entry MUST be a JS module bundled from a TOP-LEVEL frontend/ source + (frontend/customers.tsx → frontend/customers.js; the build rewrites and + verifies this — .html files are NOT valid entries). + - The module's DEFAULT EXPORT must be a React component. The host + dynamically import()s it; react / react-dom / @instatic/host-hooks are + provided via import map (leave them as plain imports). + - Read plugin context with hooks from '@instatic/host-hooks': + usePluginContext() → { pluginId, settings, grantedPermissions, routes, … } + ctx.routes.fetch(path) / ctx.routes.json(path, schema?) call this plugin's + own runtime routes with credentials. + Prefer kind:'resource' whenever the page is fundamentally a record table.`, + }, + server: { + summary: 'server/index.ts — hooks, routes, storage, settings, content, schedule', + content: `# Server entrypoint (server/index.ts — QuickJS sandbox) + +Default-export lifecycle hooks; each receives api: ServerPluginApi: + +import type { ServerPluginModule } from '@instatic/plugin-sdk' +const mod: ServerPluginModule = { + activate(api) { … }, // every boot + after install/upgrade + install(api) { … }, // once, first install + deactivate(api) { … }, + uninstall(api) { … }, + migrate(ctx, api) { … }, // upgrades; ctx.fromVersion; make idempotent +} +export default mod + +api.plugin: { id, version, permissions, log(...), assetUrl(path) } + +api.cms.routes (permission "cms.routes") — paths mount under +/admin/api/cms/plugins//runtime/; browser code builds the URL +with sitePluginRoute('', '') from '@instatic/plugin-sdk': + api.cms.routes.get('/list', 'plugins.read', handler) // capability-gated + api.cms.routes.authenticated.post('/save', handler) // any signed-in admin + api.cms.routes.public.get('/webhook', handler) // needs "cms.routes.public" +Handler: (ctx) => value — ctx = { req, body (pre-parsed JSON/form), user | null }. +Returned values are JSON-serialized (status 200); return +{ status, headers?, body } for raw responses. + +api.cms.storage.collection('') (permission "cms.storage"): + .list({ …options }) / .create(data) / .update(id, data) / .delete(id) + Records persist in the host DB — survive restarts. Declare the resource in + the manifest "resources" array. + +api.cms.settings — read/replace operator settings declared in the manifest. +api.cms.schedule (permission "cms.schedule") — cadence handlers (daily/hourly/every-minutes). +api.cms.hooks (permission "cms.hooks") — host event subscriptions. +api.cms.content (permissions "cms.content.*" + manifest contentAccess[]): + .tables.list()/.get(slug); .table(slug).list/get/getBySlug/create/update/ + delete/publish/createMany/updateMany/deleteMany; + .tree(entryId, fieldId).read/mutate/replace (page-tree operations); + .search(query); .getPublishedSnapshot(entryId); .republishAll() +api.cms.loops.registerSource — loop entity sources (id "."). +api.cms.media — storage adapter / URL transformer / variant delegate registration. + +Sandbox rules: no Node/Bun ambient APIs; fetch() only with "network.outbound" + +networkAllowedHosts; imports limited to your own files + '@instatic/plugin-sdk'. +NO persistent in-memory state across restarts — use storage collections.`, + }, + modules: { + summary: 'modules/*.ts — defineModule canvas blocks, controls, html helper', + content: `# Canvas modules (modules/*.ts — permission "modules.register") + +Each top-level modules/ file default-exports defineModule({...}): + +import { control, defineModule, html } from '@instatic/plugin-sdk' +export default defineModule({ + id: 'site..', // namespace-locked to the plugin id + name: 'Display name', + description: '…', + category: 'Group label', // module inserter section + htmlTag: 'div', + defaults: { message: 'Hello' }, // prop defaults + schema: { // right-panel controls per prop + message: control.text('Message'), + }, + render: ({ props }) => ({ + html: html\`
\${props.message}
\`, + css: '.my-block { padding: 12px; }', + }), +}) + +control builders: text, textarea, number, color, select(label, options), +toggle, image, url. +html\`…\` escapes interpolations by default; raw(str) opts out; escapeHtml / +safeUrl available. render runs in the publisher sandbox AND the editor +canvas preview — keep it pure (props in, { html, css } out). +The editor previews DRAFT modules live (no activation needed): use the +preview action in the IDE header.`, + }, + editor: { + summary: 'editor/index.ts — unsandboxed admin extension: commands, toolbar, panels, store', + content: `# Editor extension (editor/index.ts — permission "editor.code") + +Runs UNSANDBOXED in the admin window (that's why activation shows a red +consent warning). Export an activate function: + +export function activate(api) { + api.editor.commands.register({ // permission "editor.commands" + id: 'site..my-command', + label: 'My command', + run: () => { … }, + }) +} + +api.editor surfaces (each gated by its own permission): +- commands.register — command palette + programmatic runs ("editor.commands") +- toolbar.register — toolbar buttons ("editor.toolbar") +- palette.register — palette-only commands with subtitles ("editor.commands") +- panels.register — custom editor panels; panel id must start with the plugin id ("editor.panels") +- store.read(fn) / store.transaction(mutate) — read or mutate the live editor + store ("editor.store.read" / "editor.store.write"); transactions ride the + host's undo + collab machinery +Canvas overlays: "editor.canvas". + +Keep editor code minimal — it ships to every admin's browser on every load.`, + }, + frontend: { + summary: 'frontend/ assets — bundling rules, published-page injection, admin app entries', + content: `# Frontend assets (frontend/) + +Bundling: every TOP-LEVEL frontend/.(ts|tsx|js|mjs) becomes +frontend/.js in the package. Nested folders (frontend/utils/…) are +helpers only — imported into top-level entries, not bundled separately. +CSS files are copied as-is. + +Two consumers: +1. Published pages — declare tags in the manifest (permission "frontend.assets"): + "frontend": { "assets": [ + { "kind": "script", "src": "frontend/widget.ts", "placement": "body-end" }, + { "kind": "style", "href": "frontend/widget.css" } + ] } + The build rewrites .ts→.js. The host injects the tags into every published + page and adjusts CSP. Use api.plugin.assetUrl(path) server-side for URLs. +2. Admin app pages — adminPages kind:'app' entries point at a built + frontend/.js whose default export is a React component (see the + admin-pages topic). react / @instatic/host-hooks resolve via import map — + import them normally, they are NOT bundled. + +Browser → backend: call your own routes with +sitePluginRoute('', '/path') from '@instatic/plugin-sdk' +(equals /admin/api/cms/plugins/site./runtime/path).`, + }, + workflow: { + summary: 'validate → activate lifecycle, states, consent, publish coupling, rollback', + content: `# Build & activation workflow + +1. Edit files (live CRDT — no save step). plugin_validate runs the REAL build: + bundling, import containment, manifest coherence. Iterate until clean. +2. plugin_activate builds a new revision (version 1.0.+) and + runs the install/upgrade lifecycle. Same-grant rebuilds run without + friction; a CHANGED permission set (including first activation) is a human + consent moment — ask the user to click "Build & activate" in the IDE header. +3. Visitor-facing surfaces (modules pack, frontend assets) trigger an + automatic republish of baked pages on activation. + +States: active · draft-changed (source differs from the active revision; a +changed permission set is a draft change too — the review happens on the +activation click) · build-failed · runtime-error · disabled · source-missing. +plugin_list_plugins reports them, plus newPermissions / removedPermissions. + +Settings + secrets survive rebuilds. The five most recent builds are +retained; the IDE header menu can roll back to any of them (the user does +this, there is no tool for it). Deleting the plugin removes the runtime row +AND the draft folder. + +Gotchas: +- diagnostics are build/containment/manifest errors, NOT TypeScript type checks; +- in-memory server state resets on restarts/rebuilds — persist via storage; +- a patch hash mismatch means the file changed (user typing) — re-read, retry.`, + }, + examples: { + summary: 'complete minimal examples: routes backend, canvas module, resource CRUD page', + content: `# Examples + +## Backend route + browser fetch +plugin.json: { "name": "Status", "permissions": ["cms.routes"] } +server/index.ts: + import type { ServerPluginModule } from '@instatic/plugin-sdk' + const mod: ServerPluginModule = { + activate(api) { + api.cms.routes.get('/status', 'plugins.read', () => ({ + ok: true, version: api.plugin.version, + })) + }, + } + export default mod +Browser side: fetch(sitePluginRoute('', '/status'), { credentials: 'same-origin' }) + +## Canvas module +plugin.json: { "name": "Notice", "permissions": ["modules.register"] } +modules/notice.ts: + import { control, defineModule, html } from '@instatic/plugin-sdk' + export default defineModule({ + id: 'site..notice', + name: 'Notice', description: 'Callout box', category: 'Notice', + htmlTag: 'div', + defaults: { message: 'Heads up!' }, + schema: { message: control.text('Message') }, + render: ({ props }) => ({ + html: html\`
\${props.message}
\`, + css: '.notice { padding: 12px; border-radius: 6px; }', + }), + }) + +## Customers CRUD page (records managed from the admin nav — NO custom code) +plugin.json: + { + "name": "Customers", + "permissions": ["admin.navigation", "cms.storage"], + "resources": [{ + "slug": "customers", + "labelField": "name", + "fields": [ + { "id": "name", "label": "Name", "type": "text", "required": true }, + { "id": "email", "label": "Email", "type": "text" }, + { "id": "active", "label": "Active", "type": "boolean" } + ] + }], + "adminPages": [{ + "id": "customers", + "title": "Customers", + "navLabel": "Customers", + "content": { "kind": "resource", "heading": "Customers", "resource": "customers" } + }] + } +(cms.storage lets optional server code read the same records: +api.cms.storage.collection('customers').list())`, + }, +} + +const TOPIC_IDS = Object.keys(TOPICS).sort() + +const DocsInputSchema = Type.Object({ + topic: Type.Optional(Type.String({ minLength: 1 })), +}) + +export const pluginDocsTool: AiTool = { + name: 'plugin_docs', + scope: 'plugin', + execution: 'server', + description: + `Authoritative plugin-development reference. Call WITHOUT topic for the index; with topic for the full contract. Topics: ${TOPIC_IDS.join(', ')}. Read the matching topic BEFORE writing code in an area you have not touched in this conversation — it is cheaper than discovering contracts through build errors.`, + inputSchema: DocsInputSchema, + handler: async (input) => { + const { topic } = input as { topic?: string } + if (!topic) { + return aiToolOk({ + topics: TOPIC_IDS.map((id) => ({ topic: id, summary: TOPICS[id]!.summary })), + }) + } + const entry = TOPICS[topic] + if (!entry) { + return aiToolError(`Unknown docs topic "${topic}". Available: ${TOPIC_IDS.join(', ')}.`) + } + return aiToolOk({ topic, content: entry.content }) + }, +} diff --git a/server/ai/tools/plugin/fileTools.ts b/server/ai/tools/plugin/fileTools.ts new file mode 100644 index 000000000..95c7ecdfd --- /dev/null +++ b/server/ai/tools/plugin/fileTools.ts @@ -0,0 +1,119 @@ +/** + * Plugin-scope file tools — browser-bridged. + * + * All file access goes through the live Plugin IDE session (the CRDT doc is + * the source of truth while an editor is open): reads return exactly what + * the user sees — including keystrokes still inside the relay's persist + * debounce — and writes merge character-level with concurrent human typing + * instead of clobbering it. The server defines schema + description only; + * `src/admin/pages/plugins/ide/agent/pluginBridge.ts` applies each call + * against the IdeCollabSession. + * + * Shapes mirror the site code-asset tools: `plugin_read_file` paginates and + * returns a SHA-256 content hash; `plugin_patch_file` requires the latest + * `expectedHash` so stale edits fail instead of silently overwriting a + * peer's (or the user's) newer content. + * + * Capability gates mirror the HTTP/relay equivalents: reads need site.read + * or plugins.read (the IDE list/read surface), writes need `plugins.edit` + * (the relay's `plugins` write category). + */ +import { + PluginDeleteFileInputSchema, + PluginListFilesInputSchema, + PluginOpenFileInputSchema, + PluginPatchFileInputSchema, + PluginReadFileInputSchema, + PluginRenameFileInputSchema, + PluginWriteFileInputSchema, +} from '@core/ai' +import type { CoreCapability } from '@core/capabilities' +import type { AiTool } from '../types' + +const PLUGIN_READ_CAPS: readonly CoreCapability[] = ['site.read', 'plugins.read'] +const PLUGIN_EDIT_CAPS: readonly CoreCapability[] = ['plugins.edit'] + +const listFilesTool: AiTool = { + name: 'plugin_list_files', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_READ_CAPS, + description: + 'List the open plugin\'s source files from the live IDE session. Returns each file as { fileId, path, size, hash } — paths are relative to the plugin folder (plugin.json, server/index.ts, …). Use the returned hash as expectedHash for plugin_patch_file.', + inputSchema: PluginListFilesInputSchema, +} + +const readFileTool: AiTool = { + name: 'plugin_read_file', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_READ_CAPS, + description: + 'Read one plugin source file by fileId or relative path. Returns the exact content slice, the full-file SHA-256 hash, and pageInfo for pagination. If pageInfo.nextPart is not null, call plugin_read_file again with the same file and part.', + inputSchema: PluginReadFileInputSchema, +} + +const writeFileTool: AiTool = { + name: 'plugin_write_file', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_EDIT_CAPS, + description: + 'Create a new plugin source file, or overwrite an existing one, at a path relative to the plugin folder. Prefer plugin_patch_file for edits to existing files — a whole-file write replaces concurrent edits wholesale. The folder layout is meaningful: server/index.ts becomes the sandboxed backend entrypoint, editor/index.ts the admin editor extension (requires the editor.code permission), modules/*.ts canvas modules (requires modules.register), frontend/ published assets.', + inputSchema: PluginWriteFileInputSchema, +} + +const patchFileTool: AiTool = { + name: 'plugin_patch_file', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_EDIT_CAPS, + description: + 'Patch an existing plugin file by exact text replacement. Requires the latest `expectedHash` from plugin_read_file/plugin_list_files to prevent stale edits. Each replacement\'s oldText must match exactly; if oldText occurs multiple times, make it more specific or set replaceAll:true. Edits merge live with anyone typing in the same file.', + inputSchema: PluginPatchFileInputSchema, +} + +const renameFileTool: AiTool = { + name: 'plugin_rename_file', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_EDIT_CAPS, + description: + 'Rename/move one plugin file to a new path relative to the plugin folder. Renaming across entrypoint folders changes what the build derives (e.g. moving a file into editor/ makes it admin-side code requiring editor.code).', + inputSchema: PluginRenameFileInputSchema, +} + +const deleteFileTool: AiTool = { + name: 'plugin_delete_file', + scope: 'plugin', + execution: 'browser', + requiredCapabilities: PLUGIN_EDIT_CAPS, + description: + 'Delete one plugin source file from the live draft (for every editor). plugin.json cannot be deleted — every plugin needs its manifest.', + inputSchema: PluginDeleteFileInputSchema, +} + +const openFileTool: AiTool = { + name: 'plugin_open_file', + scope: 'plugin', + execution: 'browser', + description: + 'Visibly open a plugin file in the IDE buffer so the user can watch the work. Pure editor-state switch — reading does not require it (plugin_read_file works in the background).', + inputSchema: PluginOpenFileInputSchema, +} + +export const pluginFileTools: AiTool[] = [ + listFilesTool, + readFileTool, + writeFileTool, + patchFileTool, + renameFileTool, + deleteFileTool, + openFileTool, +] + +/** Browser-bridged tools that read/navigate but do not change plugin source. */ +export const PLUGIN_FILE_READ_ONLY_NAMES = new Set([ + 'plugin_list_files', + 'plugin_read_file', +]) diff --git a/server/ai/tools/plugin/index.ts b/server/ai/tools/plugin/index.ts new file mode 100644 index 000000000..f3fa97569 --- /dev/null +++ b/server/ai/tools/plugin/index.ts @@ -0,0 +1,38 @@ +/** + * Plugin-scope tool barrel — exports the toolset, system-prompt builder, + * and snapshot schema. + * + * The chat handler imports `pluginTools` for `scope === 'plugin'` and + * `buildPluginSystemPrompt` when assembling the prompt for a plugin-scope + * conversation. The MCP registry imports `pluginTools` too — file tools + * relay to the open Plugin IDE; lifecycle tools run headless. + */ +import type { AiTool } from '../types' +import { pluginDocsTool } from './docs' +import { pluginFileTools, PLUGIN_FILE_READ_ONLY_NAMES } from './fileTools' +import { + pluginLifecycleTools, + PLUGIN_LIFECYCLE_READ_ONLY_NAMES, +} from './lifecycleTools' + +// Stamp the `mutates` flag so `selectToolsForScope` can filter write tools +// out for callers without `ai.tools.write`. `plugin_open_file` counts as +// mutating (it moves the user's visible buffer — same policy as +// content_set_active_document). +export const pluginTools: AiTool[] = [ + { ...pluginDocsTool, mutates: false }, + ...pluginFileTools.map((t) => ({ + ...t, + mutates: !PLUGIN_FILE_READ_ONLY_NAMES.has(t.name), + })), + ...pluginLifecycleTools.map((t) => ({ + ...t, + mutates: !PLUGIN_LIFECYCLE_READ_ONLY_NAMES.has(t.name), + })), +] + +export { buildPluginSystemPrompt } from './systemPrompt' +// The snapshot schema lives in @core/ai so the IDE and the server derive +// the same type; re-exported here for the chat handler's scope wiring. +export { PluginIdeSnapshotSchema, emptyPluginIdeSnapshot } from '@core/ai' +export type { PluginIdeSnapshot } from '@core/ai' diff --git a/server/ai/tools/plugin/lifecycleTools.ts b/server/ai/tools/plugin/lifecycleTools.ts new file mode 100644 index 000000000..0214eeb36 --- /dev/null +++ b/server/ai/tools/plugin/lifecycleTools.ts @@ -0,0 +1,146 @@ +/** + * Plugin-scope lifecycle tools — server-resolved. + * + * These run in-process (the build and the activation lifecycle are server + * operations), which also makes them the plugin scope's headless MCP + * surface: an external client can list/validate/activate without an open + * IDE, following the `site_publish` precedent. + * + * `plugin_activate` deliberately cannot approve a grant-set change: the + * permission review + step-up is a HUMAN consent moment (the IDE header + * owns it). A grants-changed activation returns a clear instruction + * instead. Same-grant rebuilds — the everyday agent loop of edit → + * validate → activate — run without friction. + * + * The activation engine + list projection live with the site-plugins + * handler module (`runSitePluginActivation`, `listSitePlugins`), which owns + * that orchestration for every caller; these tools are thin adapters. + */ +import { Type } from '@core/utils/typeboxHelpers' +import type { CoreCapability } from '@core/capabilities' +import { aiToolError, aiToolOk } from '@core/ai' +import type { AiTool } from '../types' +import type { ToolContext } from '../../runtime/types' +import { + buildDraftSitePlugin, + listSitePlugins, + runSitePluginActivation, +} from '../../../handlers/cms/sitePlugins' +import { findUserById } from '../../../repositories/users' +import { PluginIdeSnapshotSchema } from '@core/ai' +import { safeParseValue } from '@core/utils/typeboxHelpers' + +const PLUGIN_READ_CAPS: readonly CoreCapability[] = ['site.read', 'plugins.read'] + +/** + * `localId` is optional in the panel (defaults to the plugin open in the + * IDE, from the snapshot) and required over MCP (snapshot is null there). + */ +const LocalIdInputSchema = Type.Object({ + localId: Type.Optional(Type.String({ minLength: 1 })), +}) + +function resolveLocalId(input: { localId?: string }, ctx: ToolContext): string | null { + if (input.localId) return input.localId + const parsed = safeParseValue(PluginIdeSnapshotSchema, ctx.snapshot) + return parsed.ok && parsed.value.localId ? parsed.value.localId : null +} + +const listPluginsTool: AiTool = { + name: 'plugin_list_plugins', + scope: 'plugin', + execution: 'server', + requiredCapabilities: PLUGIN_READ_CAPS, + description: + 'List every site plugin (draft folders ∪ runtime rows) with its computed state, active version, declared vs granted permissions, and last runtime error. Use to orient before working on a plugin, or to find the localId for plugin_validate / plugin_activate.', + inputSchema: Type.Object({}), + handler: async (_input, ctx) => { + return aiToolOk({ sitePlugins: await listSitePlugins(ctx.db, ctx.uploadsDir) }) + }, +} + +const validateTool: AiTool = { + name: 'plugin_validate', + scope: 'plugin', + execution: 'server', + requiredCapabilities: PLUGIN_READ_CAPS, + description: + 'Run the same validate-only build as the IDE diagnostics strip: TypeScript bundling, import containment, and manifest coherence. Returns { ok, diagnostics }. Iterate until diagnostics is empty before calling plugin_activate. Omit localId to validate the plugin open in the IDE.', + inputSchema: LocalIdInputSchema, + handler: async (input, ctx) => { + const localId = resolveLocalId(input as { localId?: string }, ctx) + if (!localId) { + return aiToolError('No plugin in scope — pass localId (see plugin_list_plugins).') + } + const result = await buildDraftSitePlugin(ctx.db, localId) + if (!result) { + return aiToolError(`No site plugin source at plugins/${localId}/`) + } + return aiToolOk({ ok: result.ok, diagnostics: result.ok ? [] : result.diagnostics }) + }, +} + +const activateTool: AiTool = { + name: 'plugin_activate', + scope: 'plugin', + execution: 'server', + requiredCapabilities: ['plugins.install'], + description: + 'Build & activate the plugin from the current draft source (install or upgrade, with publish coupling for visitor-facing surfaces). Same-grant rebuilds run directly; if the declared permission set CHANGED, activation is refused — a human must review and confirm the new grants in the IDE header (Build & activate). Run plugin_validate first. Omit localId to activate the plugin open in the IDE.', + inputSchema: LocalIdInputSchema, + handler: async (input, ctx) => { + const localId = resolveLocalId(input as { localId?: string }, ctx) + if (!localId) { + return aiToolError('No plugin in scope — pass localId (see plugin_list_plugins).') + } + if (!ctx.uploadsDir) { + return aiToolError('Uploads directory is not configured on this server.') + } + const user = await findUserById(ctx.db, ctx.userId) + if (!user) return aiToolError('Calling user no longer exists.') + + const result = await runSitePluginActivation({ + db: ctx.db, + options: { uploadsDir: ctx.uploadsDir }, + user, + req: null, + localId, + allowGrantChange: false, + }) + switch (result.status) { + case 'ok': + return aiToolOk({ + activated: !result.skipped, + skipped: result.skipped, + pluginId: result.plugin.id, + version: result.plugin.version, + ...(result.upgrade ? { upgrade: result.upgrade } : {}), + ...(result.warning ? { warning: result.warning } : {}), + }) + case 'disabled': + return aiToolError(result.message) + case 'grants-changed': + return aiToolError( + `Activation needs human consent: the permission grant set changed ` + + `(new: ${result.newPermissions.join(', ') || 'none'}; removed: ${result.removedPermissions.join(', ') || 'none'}). ` + + `Ask the user to click "Build & activate" in the Plugin IDE header to review and confirm the grants.`, + ) + case 'build-failed': + return aiToolError( + `Build failed:\n${result.diagnostics.join('\n')}\nFix the diagnostics (plugin_validate) and retry.`, + ) + case 'not-found': + case 'invalid': + case 'upgrade-error': + return aiToolError(result.message) + } + }, +} + +export const pluginLifecycleTools: AiTool[] = [listPluginsTool, validateTool, activateTool] + +/** Server tools that only read state. */ +export const PLUGIN_LIFECYCLE_READ_ONLY_NAMES = new Set([ + 'plugin_list_plugins', + 'plugin_validate', +]) diff --git a/server/ai/tools/plugin/systemPrompt.ts b/server/ai/tools/plugin/systemPrompt.ts new file mode 100644 index 000000000..1de456a6a --- /dev/null +++ b/server/ai/tools/plugin/systemPrompt.ts @@ -0,0 +1,85 @@ +/** + * Plugin-scope system prompt. + * + * Same [staticPrefix, BOUNDARY, dynamicSuffix] shape as the site/content + * scopes so Anthropic's prompt cache covers everything before the boundary. + * The dynamic suffix carries per-request context: the open plugin, its file + * list, runtime state, and the latest diagnostics. + */ +import { SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from '../../runtime/types' +import type { PluginIdeSnapshot } from '@core/ai' + +const STATIC_PROMPT_PREFIX = `You are a plugin developer working inside the Instatic Plugin IDE, co-editing a SITE PLUGIN's source with the user in real time. You read and write files by calling tools; edits merge live into the user's open editor (CRDT) — there is no save step. + +What a site plugin is: +- Source lives in the site draft under plugins//. All tool paths are RELATIVE to that folder (plugin.json, server/index.ts, …). +- plugin.json is the manifest the author owns: { name, description, permissions?, networkAllowedHosts?, resources?, adminPages? }. Everything else (id, version, entrypoints) is DERIVED by the build — never write those fields. +- Entrypoints are derived from the folder convention: + server/index.ts → backend entrypoint, runs in a QuickJS-WASM sandbox on the server (no Node/Bun ambient access; outbound network only with the network.outbound permission + networkAllowedHosts). + editor/index.ts → admin editor extension, runs UNSANDBOXED in every admin's browser — requires the "editor.code" permission. + modules/*.ts(x) → canvas module pack (blocks users drop on pages) — requires "modules.register". + frontend/** → static assets published with the site. +- Permission coherence is enforced: an entry file whose permission is missing from plugin.json fails validation with a named diagnostic. adminPages requires "admin.navigation"; adminPages with kind:'app' additionally requires "editor.code" (markdown pages don't). +- Import containment: plugin code may import its own files and "@instatic/plugin-sdk" ONLY. Imports that escape the plugin folder fail the build. There is NO node_modules in the file tree — never try to read SDK sources/types as files; this summary is your API reference. + +Admin pages (plugin.json "adminPages": [{ id, title, navLabel?, icon?, content }]) — content is a strict union: +- { "kind": "markdown", "body": "…markdown…", "heading"? } — static page, no code, no extra permission beyond admin.navigation. +- { "kind": "resource", "heading": "…", "resource": "" } — a FULL CRUD table UI (list/create/edit/delete records) rendered by the host for a resource declared in "resources": [{ "slug", "labelField", "fields": [...] }]. This is the way to build "manage X records" pages (customers, leads, bookings) — no custom code needed; records live in plugin storage. +- { "kind": "map", "heading": "…", "pins"? } — pin map page. +- { "kind": "app", "heading": "…", "entry": "" } — custom JS page; heavyweight: needs a built frontend asset and the editor.code permission. Prefer "resource" for record management. + +SDK essentials (import from "@instatic/plugin-sdk"; full contracts in plugin_docs): +- Server: default-export a ServerPluginModule ({ activate(api), install, deactivate, uninstall, migrate }). api.cms.routes.get(path, capability, handler) registers routes under /admin/api/cms/plugins/site./runtime/ — sitePluginRoute(localId, path) builds that URL for browser code. api.cms.storage.collection(slug) persists records (survives restarts — in-memory state does NOT). +- Modules: default-export defineModule({ id, name, category, htmlTag, defaults, schema: { prop: control.text(...) }, render: ({ props }) => ({ html, css }) }); html uses the tagged html\`\` helper (escapeHtml/raw/safeUrl available). +- Editor: export function activate(api) — api.editor.commands.register({ id, label, run }) etc. (runs in the admin window). + +Workflow — read docs, validate until clean, then activate: +0. plugin_docs(topic) is the authoritative reference (topics: manifest, admin-pages, server, modules, editor, frontend, workflow, examples). Read the matching topic BEFORE writing code in an area you haven't touched this conversation — guessing contracts costs more than reading them. +1. Orient: the dynamic context below lists the open plugin and its files. plugin_read_file before editing an existing file; plugin_list_files if the layout is unclear. +2. Edit: plugin_patch_file for targeted edits (needs the latest hash — re-read after the user types), plugin_write_file to create files or rewrite wholesale, plugin_rename_file / plugin_delete_file to reorganize. plugin_open_file to show the user a file you're working on. +3. Validate: plugin_validate after each meaningful change — it runs the real build (TypeScript, containment, manifest). Fix every diagnostic; the strip in the IDE shows the same list. +4. Activate: plugin_activate ships the draft as a new revision (same permissions only — a changed grant set needs the user to confirm in the IDE header; say so instead of retrying). + +Rules: +- Bias toward action: execute the request, don't ask scoping questions. +- Never invent manifest fields, permissions, or SDK APIs. If validation names a missing permission, add exactly that permission to plugin.json. +- Respect concurrent editing: if a patch fails with a hash mismatch, re-read the file and re-apply — the user (or a peer) typed meanwhile. Never blind-overwrite with plugin_write_file after a mismatch. +- Code style: plain TypeScript, no external npm imports (they cannot resolve inside the sandbox). + +Reply: 1-2 sentences after acting. The tools change the files — the reply just narrates what changed and what's next.` + +export function buildPluginSystemPrompt(snap: PluginIdeSnapshot): string[] { + return [ + STATIC_PROMPT_PREFIX, + SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + buildDynamicSuffix(snap), + ] +} + +function buildDynamicSuffix(snap: PluginIdeSnapshot): string { + if (!snap.localId) { + return 'No plugin is open. Use plugin_list_plugins to find one, then take localId from there for plugin_validate / plugin_activate; file tools need an open IDE.' + } + const lines: string[] = [] + lines.push( + `Open plugin: ${snap.localId} (runtime id ${snap.pluginId}), state=${snap.state}, active version=${snap.activeVersion ?? 'not built yet'}.`, + ) + lines.push( + `Permissions: declared=[${snap.declaredPermissions.join(', ')}] granted=[${snap.grantedPermissions.join(', ')}].`, + ) + lines.push( + snap.files.length > 0 + ? `Files:\n${snap.files.map((file) => ` - ${file.path}`).join('\n')}` + : 'Files: (none yet — plugin_write_file creates the first one).', + ) + if (snap.activeFile) { + lines.push(`The user is looking at: ${snap.activeFile.path}.`) + } + if (snap.latestDiagnostics && snap.latestDiagnostics.length > 0) { + lines.push(`Current diagnostics:\n${snap.latestDiagnostics.map((d) => ` - ${d}`).join('\n')}`) + } else if (snap.latestDiagnostics) { + lines.push('Current diagnostics: clean.') + } + lines.push(`User: ${snap.currentUser.displayName} <${snap.currentUser.email}>.`) + return lines.join('\n') +} diff --git a/server/auth/capabilities.ts b/server/auth/capabilities.ts index 6fce8ac89..00d663b08 100644 --- a/server/auth/capabilities.ts +++ b/server/auth/capabilities.ts @@ -68,6 +68,7 @@ const adminCapabilities: CoreCapability[] = [ 'storage.migrate', 'plugins.read', 'plugins.configure', + 'plugins.edit', 'plugins.install', 'plugins.lifecycle', 'users.manage', diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 790b99203..70d9f3df4 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -58,7 +58,18 @@ import type { CollabRelay, RelayDoc } from './relay' export { SITE_SOCKET_PATH } -const SITE_WRITE_CAPABILITIES = ['site.structure.edit', 'site.content.edit', 'site.style.edit'] as const +/** + * Any of these grants WRITE on the socket (`plugins.edit` covers the Plugin + * IDE persona, which co-edits plugin source over this same relay). Holding + * ALL of them makes the connection a "full writer" that skips the per-update + * capability guard. + */ +const RELAY_WRITE_CAPABILITIES = [ + 'site.structure.edit', + 'site.content.edit', + 'site.style.edit', + 'plugins.edit', +] as const /** y-protocols/sync message types (the payload's first varUint). */ const SYNC_STEP_1 = 0 @@ -165,7 +176,7 @@ export interface CollabSocketData { /** The session identity this connection may publish over presence. */ identity: CollabPresenceIdentity /** - * True when the user holds ALL of SITE_WRITE_CAPABILITIES — the common + * True when the user holds ALL of RELAY_WRITE_CAPABILITIES — the common * case, which skips the per-update capability guard entirely. Every other * connection — partial writers (e.g. content-only editors) AND read-only * viewers (zero write capabilities) — pays a fork+diff validation per @@ -209,7 +220,7 @@ export async function handleCollabSocketUpgrade( } const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - const fullSiteWriter = SITE_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) + const fullSiteWriter = RELAY_WRITE_CAPABILITIES.every((cap) => userHasCapability(user, cap)) const upgraded = server.upgrade(req, { data: { userId: user.id, diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 3b9799ab5..d770257cd 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1359,4 +1359,15 @@ export const pgMigrations: Migration[] = [ id: '030_iso_timestamps', sql: 'select 1', }, + { + // Site plugins: provenance of an installed_plugins row. 'installed' = + // uploaded zip / JSON manifest; 'site-local' = generated from the site + // draft's plugins// source (docs/features/site-plugins.md). Display + + // lifecycle routing only — the runtime never branches on it. + id: '031_installed_plugins_source', + sql: ` + alter table installed_plugins + add column if not exists source text not null default 'installed'; + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index c88159de1..5a159a3f1 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1515,4 +1515,15 @@ export const sqliteMigrations: Migration[] = [ id: '030_iso_timestamps', sql: isoTimestampRewrite030(), }, + { + // Site plugins: provenance of an installed_plugins row. 'installed' = + // uploaded zip / JSON manifest; 'site-local' = generated from the site + // draft's plugins// source (docs/features/site-plugins.md). Display + + // lifecycle routing only — the runtime never branches on it. + id: '031_installed_plugins_source', + sql: ` + alter table installed_plugins + add column source text not null default 'installed'; + `, + }, ] diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index b7ce0e53c..0e62c4da9 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -49,6 +49,7 @@ import { handleMediaRoutes } from './media' import { handleMediaFolderRoutes } from './mediaFolders' import { handleMediaStorageAdminRoutes } from './mediaStorageAdmin' import { handlePluginsRoutes } from './plugins' +import { handleSitePluginsRoutes } from './sitePlugins' import { handleDataRoutes } from './data' import { handleDashboardRoutes } from './dashboard' import { handleFontsRoutes } from './fonts' @@ -126,6 +127,11 @@ export async function handleCmsRequest( ?? (await handleMediaStorageAdminRoutes(req, db, options)) ?? (await handleMediaRoutes(req, db)) ?? (await handlePluginsRoutes(req, db, options)) + // Site plugins — authored in the site draft, activated through the same + // plugin lifecycle. Paths (`/site-plugins/...`) are disjoint from + // `/plugins/...`; adjacency here is for reading order only. They read the + // main draft: putting plugin sources on a branch is its own phase. + ?? (await handleSitePluginsRoutes(req, db, options)) ?? (await handleDataRoutes(req, db, scope, options)) // Dashboard stats — read-only aggregate counts used by the admin // dashboard widgets. Lives after data routes so future routes diff --git a/server/handlers/cms/plugins/install.ts b/server/handlers/cms/plugins/install.ts index be2f2eec2..4cd2ad0cb 100644 --- a/server/handlers/cms/plugins/install.ts +++ b/server/handlers/cms/plugins/install.ts @@ -30,6 +30,7 @@ import { import { parsePluginManifest } from '@core/plugins/manifest' import type { InstalledPlugin, + InstalledPluginSource, PluginManifest, PluginPermission, } from '@core/plugin-sdk' @@ -208,23 +209,65 @@ interface InstallContext { // Fresh install // --------------------------------------------------------------------------- -async function installFreshFromPackage(ctx: InstallContext): Promise { - const { db, options, user, req, pluginPackage, grantedPermissions } = ctx - // `uploadsDir` was checked by the caller; assert to narrow the type. - if (!options.uploadsDir) throw new Error('uploadsDir required') +// --------------------------------------------------------------------------- +// Disk-package activation seam +// +// The lifecycle half of an install/upgrade, decoupled from HOW the package +// landed on disk. Two callers: the zip route below (writes the uploaded +// files first) and the site plugin engine (the build already wrote the +// package under uploads/plugins/site.//). One lifecycle path, +// zero site-local branches downstream. +// --------------------------------------------------------------------------- - const manifest = await writePluginPackageFiles( - options.uploadsDir, - pluginPackage.manifest, - pluginPackage.files, - ) - const installed = await installPlugin(db, manifest, grantedPermissions) +export interface ActivatePackageFromDiskInput { + db: DbClient + options: CmsHandlerOptions + user: AuthUser + /** Null for non-HTTP callers (AI tools) — audit rows then omit ip/ua. */ + req: Request | null + /** Manifest whose assetBasePath already points at files ON DISK. */ + manifest: PluginManifest + grantedPermissions: PluginPermission[] + source: InstalledPluginSource +} + +export interface ActivatePackageOutcome { + plugin: InstalledPlugin + pack: PluginPackSummary | null + upgrade?: { fromVersion: string; toVersion: string } + /** Set when an upgrade failed and rolled back — the caller reports it. */ + upgradeError?: string +} + +/** + * Fresh install or upgrade, decided by the existing-row lookup. Runs the + * full plugin lifecycle: fresh = `install` -> `activate`; upgrade = old + * `deactivate` -> row swap -> `migrate({ fromVersion })` -> `activate`, + * with rollback to the previous version on failure. Audit + event + * broadcast included. Anything already installed takes the upgrade path + * whatever its version: a same-version rebuild and a rollback to an older + * build must deactivate the running plugin before replacing its files, and + * the fresh path would run `install` on a live plugin with no rollback. + * Callers with version-ordering rules (the zip route rejects downgrades) + * enforce them BEFORE calling. + */ +export async function activatePluginPackageFromDisk( + input: ActivatePackageFromDiskInput, +): Promise { + const existingResult = await getInstalledPlugin(input.db, input.manifest.id) + const existing = existingResult?.kind === 'ok' ? existingResult.plugin : null + if (existing) return upgradeFromDisk(input, existing) + return freshInstallFromDisk(input) +} + +async function freshInstallFromDisk( + input: ActivatePackageFromDiskInput, +): Promise { + const { db, options, user, req, manifest, grantedPermissions, source } = input + const installed = await installPlugin(db, manifest, grantedPermissions, { source }) const installLifecycle = await runPluginLifecycleHook(db, installed, options, 'install', 'installed') if (!installLifecycle.ok) { - return jsonResponse( - { plugin: await presentPluginSecrets(db, installLifecycle.plugin), ...(await pluginsPayload(db)) }, - { status: 201 }, - ) + return { plugin: installLifecycle.plugin, pack: null } } // Reset worker + host state so partial registrations from `install` don't @@ -257,11 +300,33 @@ async function installFreshFromPackage(ctx: InstallContext): Promise { version: activateLifecycle.plugin.version, occurredAt: new Date().toISOString(), }) + return { plugin: activateLifecycle.plugin, pack: packSummary } +} + +async function installFreshFromPackage(ctx: InstallContext): Promise { + const { db, options, user, req, pluginPackage, grantedPermissions } = ctx + // `uploadsDir` was checked by the caller; assert to narrow the type. + if (!options.uploadsDir) throw new Error('uploadsDir required') + + const manifest = await writePluginPackageFiles( + options.uploadsDir, + pluginPackage.manifest, + pluginPackage.files, + ) + const outcome = await freshInstallFromDisk({ + db, + options, + user, + req, + manifest, + grantedPermissions, + source: 'installed', + }) return jsonResponse( { - plugin: await presentPluginSecrets(db, activateLifecycle.plugin), + plugin: await presentPluginSecrets(db, outcome.plugin), ...(await pluginsPayload(db)), - pack: packSummary, + ...(outcome.pack !== null ? { pack: outcome.pack } : {}), }, { status: 201 }, ) @@ -300,8 +365,42 @@ interface UpgradeContext extends InstallContext { async function installUpgradeFromPackage(ctx: UpgradeContext): Promise { const { db, options, user, req, existing, pluginPackage, grantedPermissions } = ctx if (!options.uploadsDir) throw new Error('uploadsDir required') + + // Write new assets, then ride the shared disk-activation upgrade path. + const newManifest = await writePluginPackageFiles( + options.uploadsDir, + pluginPackage.manifest, + pluginPackage.files, + ) + const outcome = await upgradeFromDisk( + { db, options, user, req, manifest: newManifest, grantedPermissions, source: 'installed' }, + existing, + ) + if (outcome.upgradeError) { + return jsonResponse( + { error: outcome.upgradeError, ...(await pluginsPayload(db)) }, + { status: 400 }, + ) + } + return jsonResponse( + { + plugin: await presentPluginSecrets(db, outcome.plugin), + ...(await pluginsPayload(db)), + ...(outcome.pack !== null ? { pack: outcome.pack } : {}), + upgrade: outcome.upgrade, + }, + { status: 200 }, + ) +} + +async function upgradeFromDisk( + input: ActivatePackageFromDiskInput, + existing: InstalledPlugin, +): Promise { + const { db, options, user, req, manifest: newManifest, grantedPermissions, source } = input + if (!options.uploadsDir) throw new Error('uploadsDir required') const fromVersion = existing.version - const newVersion = pluginPackage.manifest.version + const newVersion = newManifest.version const pluginId = existing.id // 1. Deactivate the old version. Best-effort — a deactivate failure @@ -309,16 +408,9 @@ async function installUpgradeFromPackage(ctx: UpgradeContext): Promise // about to replace it anyway). We log and move on. await teardownPreviousVersion(db, pluginId, existing, options.uploadsDir) - // 2. Write new assets. - const newManifest = await writePluginPackageFiles( - options.uploadsDir, - pluginPackage.manifest, - pluginPackage.files, - ) - - // 3. Replace DB row. `installPlugin` upserts — settings_json + installed_at + // 2. Replace DB row. `installPlugin` upserts — settings_json + installed_at // are preserved by the SET clause (it doesn't reference them). - const upgraded = await installPlugin(db, newManifest, grantedPermissions) + const upgraded = await installPlugin(db, newManifest, grantedPermissions, { source }) // Refresh settings cache from the upserted row (merging decrypted secrets) // so the worker's `loadPluginServerEntrypoint` seeds the right values into // the worker's local mirror. @@ -353,13 +445,14 @@ async function installUpgradeFromPackage(ctx: UpgradeContext): Promise const failureMessage = lifecycleErrorMessage(err) console.error(`[plugin:${pluginId}] upgrade ${fromVersion} → ${newVersion} failed:`, err) await rollbackUpgrade({ db, options, existing, newManifest }) - return jsonResponse( - { - error: `Upgrade failed: ${failureMessage}. Rolled back to version ${fromVersion}.`, - ...(await pluginsPayload(db)), - }, - { status: 400 }, - ) + const rolledBackResult = await getInstalledPlugin(db, pluginId) + const rolledBack = + (rolledBackResult?.kind === 'ok' ? rolledBackResult.plugin : null) ?? existing + return { + plugin: rolledBack, + pack: null, + upgradeError: `Upgrade failed: ${failureMessage}. Rolled back to version ${fromVersion}.`, + } } // 6. The old version's files STAY. Published pages link plugin frontend @@ -394,15 +487,11 @@ async function installUpgradeFromPackage(ctx: UpgradeContext): Promise toVersion: newVersion, occurredAt: new Date().toISOString(), }) - return jsonResponse( - { - plugin: await presentPluginSecrets(db, finalRow), - ...(await pluginsPayload(db)), - pack: packSummary, - upgrade: { fromVersion, toVersion: newVersion }, - }, - { status: 200 }, - ) + return { + plugin: finalRow, + pack: packSummary, + upgrade: { fromVersion, toVersion: newVersion }, + } } /** @@ -458,12 +547,13 @@ async function rollbackUpgrade(args: { const { db, options, existing, newManifest } = args const pluginId = existing.id - // Restore DB row to previous manifest + grants. The upsert preserves - // settings + installed_at automatically. + // Restore DB row to previous manifest + grants (provenance included). + // The upsert preserves settings + installed_at automatically. const restored = await installPlugin( db, pluginManifestWithGrants(existing), existing.grantedPermissions, + { source: existing.source }, ) // Drop new version assets — the upgrade didn't take. With worker diff --git a/server/handlers/cms/plugins/pack.ts b/server/handlers/cms/plugins/pack.ts index db8469f5f..dee7144d1 100644 --- a/server/handlers/cms/plugins/pack.ts +++ b/server/handlers/cms/plugins/pack.ts @@ -60,7 +60,7 @@ async function installPluginPackToSite( plugin: InstalledPlugin, uploadsDir: string, actorUserId: string, - req: Request, + req: Request | null, ): Promise { if (!plugin.manifest.pack) return null if (!plugin.manifest.assetBasePath) return null @@ -175,7 +175,7 @@ export async function maybeAutoInstallPluginPack( plugin: InstalledPlugin, options: CmsHandlerOptions, user: AuthUser, - req: Request, + req: Request | null, ): Promise { if (!options.uploadsDir) return null if (!plugin.manifest.pack) return null diff --git a/server/handlers/cms/plugins/shared.ts b/server/handlers/cms/plugins/shared.ts index 8cf45139b..332a4cad5 100644 --- a/server/handlers/cms/plugins/shared.ts +++ b/server/handlers/cms/plugins/shared.ts @@ -78,7 +78,7 @@ type PluginAuditAction = export async function recordPluginAuditEvent( db: DbClient, user: AuthUser, - req: Request, + req: Request | null, action: PluginAuditAction, pluginId: string, metadata: Record = {}, @@ -125,6 +125,7 @@ function brokenPluginStub( lifecycleStatus: 'error', lastError: result.reason, grantedPermissions: [], + source: 'installed', manifest: stubManifest, settings: {}, installedAt: new Date(0).toISOString(), diff --git a/server/handlers/cms/plugins/state.ts b/server/handlers/cms/plugins/state.ts index 47b19d504..a48744aff 100644 --- a/server/handlers/cms/plugins/state.ts +++ b/server/handlers/cms/plugins/state.ts @@ -129,31 +129,48 @@ export async function handlePluginItem( if (req.method === 'DELETE') { const force = new URL(req.url).searchParams.get('force') === 'true' - const lookup = await getInstalledPlugin(db, pluginId) - if (!lookup) return pluginNotFound() + return uninstallPluginById(req, db, options, user, pluginId, force) + } - // Lifecycle hooks run only on the normal path with a parseable manifest: - // `?force=true` is the operator's escape hatch for a throwing or - // unloadable hook, and a corrupt manifest has no valid plugin to run - // hooks on. Both skip straight to teardown. - if (!force && lookup.kind === 'ok') { - let current = lookup.plugin - // Uninstall contract: "(if active) deactivate → uninstall". Run - // deactivate first so the plugin tears down its active-state - // resources before the uninstall hook does its permanent cleanup. - if (current.lifecycleStatus === 'active') { - const deactivated = await runPluginLifecycleHook(db, current, options, 'deactivate', 'disabled') - if (!deactivated.ok) return uninstallHookFailure('deactivate', deactivated.plugin) - current = deactivated.plugin - } - const uninstalled = await runPluginLifecycleHook(db, current, options, 'uninstall', current.lifecycleStatus) - if (!uninstalled.ok) return uninstallHookFailure('uninstall', uninstalled.plugin) - } + return methodNotAllowed() +} - return removePluginCompletely(req, db, options, user, pluginId, force) +/** + * The full uninstall path — lifecycle hooks (unless forced or the manifest + * is corrupt) followed by complete teardown. Shared by the plugin DELETE + * route above and the site plugin delete route (which additionally removes + * the draft source folder). + */ +export async function uninstallPluginById( + req: Request, + db: DbClient, + options: CmsHandlerOptions, + user: AuthUser, + pluginId: string, + force: boolean, +): Promise { + const lookup = await getInstalledPlugin(db, pluginId) + if (!lookup) return pluginNotFound() + + // Lifecycle hooks run only on the normal path with a parseable manifest: + // `?force=true` is the operator's escape hatch for a throwing or + // unloadable hook, and a corrupt manifest has no valid plugin to run + // hooks on. Both skip straight to teardown. + if (!force && lookup.kind === 'ok') { + let current = lookup.plugin + // Uninstall contract: "(if active) deactivate → uninstall". Run + // deactivate first so the plugin tears down its active-state + // resources before the uninstall hook does its permanent cleanup. + if (current.lifecycleStatus === 'active') { + const deactivated = await runPluginLifecycleHook(db, current, options, 'deactivate', 'disabled') + if (!deactivated.ok) return uninstallHookFailure('deactivate', deactivated.plugin) + current = deactivated.plugin + } + const uninstalled = await runPluginLifecycleHook(db, current, options, 'uninstall', current.lifecycleStatus) + if (!uninstalled.ok) return uninstallHookFailure('uninstall', uninstalled.plugin) } - return methodNotAllowed() + return removePluginCompletely(req, db, options, user, pluginId, force) } /** diff --git a/server/handlers/cms/shared.ts b/server/handlers/cms/shared.ts index ed88a7a7c..4c91dab23 100644 --- a/server/handlers/cms/shared.ts +++ b/server/handlers/cms/shared.ts @@ -45,7 +45,13 @@ export interface CmsHandlerOptions { collabRelay?: CollabRelay } -export function requestAuditContext(req: Request): { ipAddress: string | null; userAgent: string | null } { +/** + * Audit fields extracted from the HTTP request. `null` covers actions with + * no request — e.g. AI-tool-initiated lifecycle operations, where the actor + * is recorded but there is no client ip/ua to attribute. + */ +export function requestAuditContext(req: Request | null): { ipAddress: string | null; userAgent: string | null } { + if (!req) return { ipAddress: null, userAgent: null } return { ipAddress: clientIp(req), userAgent: req.headers.get('user-agent'), diff --git a/server/handlers/cms/siteDocument.ts b/server/handlers/cms/siteDocument.ts index ad7e883e7..bb92b13f5 100644 --- a/server/handlers/cms/siteDocument.ts +++ b/server/handlers/cms/siteDocument.ts @@ -93,10 +93,15 @@ import { CMS_API_PREFIX } from './shared' import { ForbiddenSiteChangeError, validateSiteWriteDiff } from '../../writePolicy/siteDiff' import { validatePageWriteDiff } from '../../writePolicy/pageDiff' +// Any of these opens the save endpoint; the per-category diff validator +// (`validateSiteWriteDiff`) then gates each individual change. `plugins.edit` +// is included so a plugin-developer persona can persist plugin-source file +// changes — every non-plugin change still requires the matching site cap. const SITE_WRITE_CAPABILITIES = [ 'site.structure.edit', 'site.content.edit', 'site.style.edit', + 'plugins.edit', ] satisfies CoreCapability[] const SiteDocumentBodySchema = Type.Object({ diff --git a/server/handlers/cms/sitePlugins/index.ts b/server/handlers/cms/sitePlugins/index.ts new file mode 100644 index 000000000..ccf74f0cf --- /dev/null +++ b/server/handlers/cms/sitePlugins/index.ts @@ -0,0 +1,454 @@ +/** + * Site plugin admin endpoints — the authoring/lifecycle surface for plugins + * built from the site draft (docs/features/site-plugins.md). The + * transport-independent service layer (list + activation engine) lives in + * ./service.ts, shared with the AI plugin tools. + * + * GET /admin/api/cms/site-plugins — union of draft plugins/* folders and + * site-local runtime rows, with computed states + * POST /admin/api/cms/site-plugins — scaffold a new site plugin into the draft + * POST /admin/api/cms/site-plugins/:localId/validate — validate-only build → diagnostics + * GET /admin/api/cms/site-plugins/:localId/preview-pack.js — draft module pack bundle (session-local canvas preview) + * POST /admin/api/cms/site-plugins/:localId/activate — build + install/upgrade lifecycle (+ publish coupling) + * POST /admin/api/cms/site-plugins/:localId/rollback — re-activate the retained previous revision + * DELETE /admin/api/cms/site-plugins/:localId[?force=true] — uninstall runtime row + delete draft source + * + * Authority model (design → "Activation Semantics"): + * - authoring (scaffold) needs `plugins.edit`, never plugins.install — + * the same capability the collab guard requires for plugin-file writes; + * - validation/preview need no elevated capability (site.read); + * - activation needs `plugins.install`, with step-up ONLY on the consent + * moments — first activation and grant-set changes. Same-grant rebuilds + * skip both the review and the step-up. + * + * Deactivate/restart/settings/logs ride the EXISTING plugin routes by id + * (`site.` is an ordinary installed_plugins row) — no duplicates. + */ +import { readFile } from 'node:fs/promises' +import { MAIN_SCOPE } from '../../../branches/scope' +import { join } from 'node:path' +import { nanoid } from 'nanoid' +import type { SiteFile } from '@core/files/schemas' +import type { PluginManifest } from '@core/plugin-sdk' +import { parsePluginManifest } from '@core/plugins/manifest' +import { + SITE_PLUGIN_LOCAL_ID_PATTERN, + SITE_PLUGIN_TEMPLATE_IDS, + sitePluginFolder, + sitePluginIdFromLocalId, + sitePluginTemplateFiles, +} from '@core/site-plugins' +import { Type } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import type { DbClient } from '../../../db/client' +import type { AuthUser } from '../../../repositories/users' +import { + requireAnyCapability, + requireCapability, + requireStepUp, +} from '../../../auth/authz' +import { getDraftSite, saveDraftSite } from '../../../repositories/site' +import { getInstalledPlugin } from '../../../repositories/plugins' +import { runPublishFlush } from '../../../publish/publishFlush' +import { listSitePluginRevisions } from '../../../plugins/sitePlugins/retention' +import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../../http' +import { type CmsHandlerOptions } from '../shared' +import { activatePluginPackageFromDisk } from '../plugins/install' +import { presentPluginSecrets } from '../plugins/shared' +import { uninstallPluginById } from '../plugins/state' +import { + buildDraftSitePlugin, + listSitePlugins, + republishAndSweep, + runSitePluginActivation, + sameStringSet, + withSitePluginLock, +} from './service' + +// Service re-exports — external consumers (the AI plugin tools) import the +// engine through this barrel. +export { + buildDraftSitePlugin, + listSitePlugins, + runSitePluginActivation, +} from './service' +export type { SitePluginActivationResult } from './service' + +const COLLECTION_PATH = '/admin/api/cms/site-plugins' +const ITEM_PATTERN = /^\/admin\/api\/cms\/site-plugins\/(?[^/]+)$/ +const VALIDATE_PATTERN = /^\/admin\/api\/cms\/site-plugins\/(?[^/]+)\/validate$/ +const PREVIEW_PACK_PATTERN = /^\/admin\/api\/cms\/site-plugins\/(?[^/]+)\/preview-pack\.js$/ +const ACTIVATE_PATTERN = /^\/admin\/api\/cms\/site-plugins\/(?[^/]+)\/activate$/ +const ROLLBACK_PATTERN = /^\/admin\/api\/cms\/site-plugins\/(?[^/]+)\/rollback$/ + +// --------------------------------------------------------------------------- +// POST /site-plugins — scaffold +// --------------------------------------------------------------------------- + +const ScaffoldBodySchema = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 80 }), + localId: Type.String({ pattern: SITE_PLUGIN_LOCAL_ID_PATTERN.source, maxLength: 64 }), + template: Type.Union(SITE_PLUGIN_TEMPLATE_IDS.map((id) => Type.Literal(id))), +}) + +async function handleScaffold(req: Request, db: DbClient): Promise { + const body = await readValidatedBody(req, ScaffoldBodySchema) + if (!body) { + return badRequest( + 'Invalid site plugin scaffold — name, kebab-case localId, and a valid template are required', + ) + } + + await runPublishFlush() + const shell = await getDraftSite(db, MAIN_SCOPE) + if (!shell) return badRequest('No draft site exists yet') + + const folder = sitePluginFolder(body.localId) + if (shell.files.some((file) => file.path.startsWith(folder))) { + return jsonResponse( + { error: `A site plugin folder "${folder}" already exists` }, + { status: 409 }, + ) + } + const existingRow = await getInstalledPlugin(db, sitePluginIdFromLocalId(body.localId)) + if (existingRow) { + return jsonResponse( + { error: `A site plugin "${body.localId}" already has a runtime record — pick another id` }, + { status: 409 }, + ) + } + + const now = Date.now() + const created: SiteFile[] = sitePluginTemplateFiles(body.template, body.localId, body.name).map( + (file) => ({ + id: nanoid(), + path: file.path, + type: 'plugin', + content: file.content, + createdAt: now, + updatedAt: now, + }), + ) + + // Out-of-relay shell write — saveDraftSite fires notifyShellWrite, the + // relay resets the site doc, and connected editors rebind with the new + // files. The scaffold is a rare, explicit action; the reset is the + // designed path for external writers. + await saveDraftSite(db, MAIN_SCOPE, { + ...shell, + files: [...shell.files, ...created], + updatedAt: now, + }) + + return jsonResponse( + { ok: true, localId: body.localId, files: created.map((file) => file.path) }, + { status: 201 }, + ) +} + +// --------------------------------------------------------------------------- +// POST /site-plugins/:localId/validate — diagnostics without activation +// --------------------------------------------------------------------------- + +async function handleValidate(db: DbClient, localId: string): Promise { + const result = await buildDraftSitePlugin(db, localId) + if (!result) { + return jsonResponse({ error: `No site plugin source at plugins/${localId}/` }, { status: 404 }) + } + return jsonResponse({ ok: result.ok, diagnostics: result.ok ? [] : result.diagnostics }) +} + +// --------------------------------------------------------------------------- +// GET /site-plugins/:localId/preview-pack.js — session-local canvas preview +// --------------------------------------------------------------------------- + +async function handlePreviewPack(db: DbClient, localId: string): Promise { + const result = await buildDraftSitePlugin(db, localId) + if (!result) { + return jsonResponse({ error: `No site plugin source at plugins/${localId}/` }, { status: 404 }) + } + if (!result.ok) { + return jsonResponse( + { error: `Draft build failed: ${result.diagnostics.join('; ')}` }, + { status: 404 }, + ) + } + if (result.modulesBundle === undefined) { + return jsonResponse( + { error: `Site plugin "${localId}" declares no module pack` }, + { status: 404 }, + ) + } + // Draft bundles must never cache — every preview reflects the current draft. + return new Response(result.modulesBundle, { + headers: { + 'content-type': 'application/javascript; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +// --------------------------------------------------------------------------- +// POST /site-plugins/:localId/activate — Build & activate +// --------------------------------------------------------------------------- + +async function handleActivate( + req: Request, + db: DbClient, + options: CmsHandlerOptions, + user: AuthUser, + localId: string, +): Promise { + // First pass without grant-change authority: a `grants-changed` result IS + // the consent moment — step up, then re-run with the change allowed. The + // repeated pass only re-does the cheap draft read + manifest derivation + // (the grant check fails before any build work). + let result = await runSitePluginActivation({ + db, options, user, req, localId, allowGrantChange: false, + }) + if (result.status === 'grants-changed') { + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + result = await runSitePluginActivation({ + db, options, user, req, localId, allowGrantChange: true, + }) + } + + switch (result.status) { + case 'ok': + return jsonResponse({ + plugin: await presentPluginSecrets(db, result.plugin), + ...(result.upgrade ? { upgrade: result.upgrade } : {}), + ...(result.skipped ? { skipped: true } : {}), + ...(result.warning ? { warning: result.warning } : {}), + sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null), + }) + case 'not-found': + return jsonResponse({ error: result.message }, { status: 404 }) + case 'disabled': + return jsonResponse({ error: result.message }, { status: 409 }) + case 'invalid': + return result.message === 'Uploads directory is not configured' + ? jsonResponse({ error: result.message }, { status: 500 }) + : badRequest(result.message) + case 'build-failed': + return jsonResponse( + { error: 'Site plugin build failed', diagnostics: result.diagnostics }, + { status: 400 }, + ) + case 'upgrade-error': + return jsonResponse( + { error: result.message, sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null) }, + { status: 400 }, + ) + case 'grants-changed': + // Unreachable — the step-up pass above re-runs with the change allowed. + return badRequest('Activation requires grant-change consent') + } +} + +// --------------------------------------------------------------------------- +// POST /site-plugins/:localId/rollback — re-activate a retained revision +// --------------------------------------------------------------------------- + +const RollbackBodySchema = Type.Object({ + /** A retained generated version (see the summary's `revisions`). */ + version: Type.String({ minLength: 1, maxLength: 80 }), +}) + +async function handleRollback( + req: Request, + db: DbClient, + options: CmsHandlerOptions, + user: AuthUser, + localId: string, +): Promise { + if (!options.uploadsDir) { + return jsonResponse({ error: 'Uploads directory is not configured' }, { status: 500 }) + } + const body = await readValidatedBody(req, RollbackBodySchema) + if (!body) return badRequest('Pass the retained revision to roll back to as `version`') + + const pluginId = sitePluginIdFromLocalId(localId) + const rowResult = await getInstalledPlugin(db, pluginId) + const row = rowResult?.kind === 'ok' ? rowResult.plugin : null + if (!row) { + return jsonResponse({ error: `Site plugin "${localId}" has no runtime record` }, { status: 404 }) + } + + // Only a retained directory is a valid target — the version is user + // input and must never be joined into a path unchecked. + const retained = await listSitePluginRevisions(options.uploadsDir, pluginId) + const target = retained.find((revision) => revision.version === body.version) + if (!target) { + return badRequest(`Revision ${body.version} of "${localId}" is not retained`) + } + if (target.version === row.version) { + return badRequest(`Revision ${body.version} of "${localId}" is already active`) + } + + let manifest: PluginManifest + try { + const manifestPath = join( + options.uploadsDir, + 'plugins', + pluginId, + target.version, + 'plugin.json', + ) + manifest = parsePluginManifest(JSON.parse(await readFile(manifestPath, 'utf8'))) + } catch (err) { + return badRequest( + `Revision ${target.version} is unreadable: ${getErrorMessage(err, 'corrupt package')}`, + ) + } + + // Rolling back to a revision with a DIFFERENT grant set is a consent + // moment too (it may re-grant something the current draft dropped). + if (!sameStringSet(row.grantedPermissions, manifest.permissions)) { + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + } + + const outcome = await activatePluginPackageFromDisk({ + db, + options, + user, + req, + manifest, + grantedPermissions: manifest.permissions, + source: 'site-local', + }) + if (outcome.upgradeError) { + return jsonResponse( + { error: outcome.upgradeError, sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null) }, + { status: 400 }, + ) + } + + const warning = await republishAndSweep(db, options.uploadsDir, manifest) + + return jsonResponse({ + plugin: await presentPluginSecrets(db, outcome.plugin), + rolledBackTo: target.version, + ...(warning ? { warning } : {}), + sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null), + }) +} + +// --------------------------------------------------------------------------- +// DELETE /site-plugins/:localId — uninstall + delete draft source +// --------------------------------------------------------------------------- + +async function handleDelete( + req: Request, + db: DbClient, + options: CmsHandlerOptions, + user: AuthUser, + localId: string, +): Promise { + const pluginId = sitePluginIdFromLocalId(localId) + const force = new URL(req.url).searchParams.get('force') === 'true' + + // Runtime teardown first (hooks unless forced) — a hook failure aborts + // with the same "fix or force" contract as installed plugins, leaving the + // draft source untouched. `Response.ok` = HTTP 2xx here. + const rowResult = await getInstalledPlugin(db, pluginId) + if (rowResult) { + const teardown = await uninstallPluginById(req, db, options, user, pluginId, force) + if (!teardown.ok) return teardown + } + + // Then the draft source folder. + await runPublishFlush() + const shell = await getDraftSite(db, MAIN_SCOPE) + if (shell) { + const folder = sitePluginFolder(localId) + const remaining = shell.files.filter((file) => !file.path.startsWith(folder)) + if (remaining.length !== shell.files.length) { + await saveDraftSite(db, MAIN_SCOPE, { ...shell, files: remaining, updatedAt: Date.now() }) + } + } + + return jsonResponse({ ok: true, sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null) }) +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +export async function handleSitePluginsRoutes( + req: Request, + db: DbClient, + options: CmsHandlerOptions, +): Promise { + const { pathname } = new URL(req.url) + if (pathname !== COLLECTION_PATH && !pathname.startsWith(`${COLLECTION_PATH}/`)) return null + + if (pathname === COLLECTION_PATH) { + if (req.method === 'GET') { + // The Plugins page reads with plugins.read; the IDE's status chip works + // for pure site developers via site.read. + const user = await requireAnyCapability(req, db, ['plugins.read', 'site.read']) + if (user instanceof Response) return user + return jsonResponse({ sitePlugins: await listSitePlugins(db, options.uploadsDir ?? null) }) + } + if (req.method === 'POST') { + // Scaffolding writes plugin source into the draft — the authoring + // capability, NOT a plugin power grant (activation is where powers + // happen). Same gate as the collab guard on plugin-file writes. + const user = await requireCapability(req, db, 'plugins.edit') + if (user instanceof Response) return user + return handleScaffold(req, db) + } + return methodNotAllowed() + } + + const validateMatch = VALIDATE_PATTERN.exec(pathname) + if (validateMatch?.groups) { + if (req.method !== 'POST') return methodNotAllowed() + const user = await requireAnyCapability(req, db, ['plugins.read', 'site.read']) + if (user instanceof Response) return user + return handleValidate(db, validateMatch.groups['localId']!) + } + + const previewMatch = PREVIEW_PACK_PATTERN.exec(pathname) + if (previewMatch?.groups) { + if (req.method !== 'GET') return methodNotAllowed() + const user = await requireAnyCapability(req, db, ['plugins.read', 'site.read']) + if (user instanceof Response) return user + return handlePreviewPack(db, previewMatch.groups['localId']!) + } + + const activateMatch = ACTIVATE_PATTERN.exec(pathname) + if (activateMatch?.groups) { + if (req.method !== 'POST') return methodNotAllowed() + const user = await requireCapability(req, db, 'plugins.install') + if (user instanceof Response) return user + return handleActivate(req, db, options, user, activateMatch.groups['localId']!) + } + + const rollbackMatch = ROLLBACK_PATTERN.exec(pathname) + if (rollbackMatch?.groups) { + if (req.method !== 'POST') return methodNotAllowed() + const user = await requireCapability(req, db, 'plugins.install') + if (user instanceof Response) return user + const localId = rollbackMatch.groups['localId']! + return withSitePluginLock(localId, () => handleRollback(req, db, options, user, localId)) + } + + const itemMatch = ITEM_PATTERN.exec(pathname) + if (itemMatch?.groups) { + if (req.method !== 'DELETE') return methodNotAllowed() + // Deleting a site plugin is the install operation's inverse — same + // capability + step-up as uninstalling an installed plugin. + const user = await requireCapability(req, db, 'plugins.install') + if (user instanceof Response) return user + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + const localId = itemMatch.groups['localId']! + return withSitePluginLock(localId, () => handleDelete(req, db, options, user, localId)) + } + + return jsonResponse({ error: 'Not found' }, { status: 404 }) +} diff --git a/server/handlers/cms/sitePlugins/service.ts b/server/handlers/cms/sitePlugins/service.ts new file mode 100644 index 000000000..8d017da26 --- /dev/null +++ b/server/handlers/cms/sitePlugins/service.ts @@ -0,0 +1,405 @@ +/** + * Site plugin service layer — the transport-independent half of the site + * plugin surface, shared by the HTTP routes (./index.ts) and the AI + * `plugin_*` tools (server/ai/tools/plugin/lifecycleTools.ts): + * + * - `listSitePlugins` — union of draft folders and site-local + * runtime rows with computed states + * - `runSitePluginActivation` — the Build & activate engine. HTTP-free: + * consent (step-up) stays with the HTTP + * wrapper; callers that cannot prompt pass + * `allowGrantChange: false` and surface the + * `grants-changed` result as an instruction + * to confirm in the IDE header + * - `republishAndSweep` — the activation/rollback publish coupling + */ +import type { SiteFile } from '@core/files/schemas' +import { MAIN_SCOPE } from '../../../branches/scope' +import type { InstalledPlugin, PluginManifest } from '@core/plugin-sdk' +import { + computeSitePluginContentHash, + computeSitePluginState, + contentHashOfVersion, + deriveSitePluginManifest, + discoverSitePlugins, + sitePluginIdFromLocalId, + type SitePluginSummary, +} from '@core/site-plugins' +import { getErrorMessage } from '@core/utils/errorMessage' +import type { DbClient } from '../../../db/client' +import type { AuthUser } from '../../../repositories/users' +import { getDraftSite } from '../../../repositories/site' +import { getInstalledPlugin, listInstalledPlugins } from '../../../repositories/plugins' +import { runPublishFlush } from '../../../publish/publishFlush' +import { republishAllDataRows, republishAllPages } from '../../../publish/republish' +import { + bumpPublishVersion, + getPublishVersion, + withPublishLock, +} from '../../../publish/publishState' +import { + buildSitePlugin, + type SitePluginBuildResult, +} from '../../../plugins/sitePlugins/build' +import { + listSitePluginRevisions, + sweepSitePluginRevisions, +} from '../../../plugins/sitePlugins/retention' +import { createKeyedSerializer } from '../../../util/keyedSerial' +import type { CmsHandlerOptions } from '../shared' +import { activatePluginPackageFromDisk } from '../plugins/install' + +// --------------------------------------------------------------------------- +// Per-plugin lifecycle lock +// --------------------------------------------------------------------------- + +const serializeLifecycle = createKeyedSerializer() + +/** + * Serialize lifecycle transitions (activate, rollback, delete) per local id. + * Two overlapping activations would otherwise both read the same row + * version, derive the same next counter, and race the upgrade path — the + * build below is single-flight on its own, but the read-row → derive → + * activate window around it was not. + */ +export function withSitePluginLock(localId: string, fn: () => Promise): Promise { + return serializeLifecycle(localId, fn) +} + +// --------------------------------------------------------------------------- +// Draft access +// --------------------------------------------------------------------------- + +/** + * Read the persisted draft's plugin files — flushing the collab relay first + * so edits still inside the persist debounce window are included ("flush + * the editor's pending save, then read the persisted draft"). + */ +export async function readDraftPluginFiles(db: DbClient): Promise { + await runPublishFlush() + const shell = await getDraftSite(db, MAIN_SCOPE) + return shell?.files.filter((file) => file.type === 'plugin') ?? [] +} + +/** + * Validate-only build of one draft plugin — the diagnostics strip, the + * preview-pack route and the AI `plugin_validate` tool all run exactly + * this. Null when no `plugins//` source exists. + */ +export async function buildDraftSitePlugin( + db: DbClient, + localId: string, +): Promise { + const draftFiles = await readDraftPluginFiles(db) + const plugin = discoverSitePlugins(draftFiles).find((entry) => entry.localId === localId) + if (!plugin) return null + const rowResult = await getInstalledPlugin(db, sitePluginIdFromLocalId(localId)) + const row = rowResult?.kind === 'ok' ? rowResult.plugin : null + return buildSitePlugin({ + localId, + files: plugin.files, + previousVersion: row?.version ?? null, + validateOnly: true, + }) +} + +export function sameStringSet(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort() + const sortedB = [...b].sort() + return sortedA.every((value, index) => value === sortedB[index]) +} + +// --------------------------------------------------------------------------- +// GET /site-plugins — the list +// --------------------------------------------------------------------------- + +/** + * @param uploadsDir Where built revisions live; null (no uploads configured) + * lists every plugin with an empty `revisions` array. + */ +export async function listSitePlugins( + db: DbClient, + uploadsDir: string | null, +): Promise { + const draftFiles = await readDraftPluginFiles(db) + let discovered: ReturnType + try { + discovered = discoverSitePlugins(draftFiles) + } catch (err) { + // An invalid local id in the draft must not blank the whole list — the + // offending folder simply can't be discovered until renamed. + console.error('[site-plugins] draft discovery failed:', err) + discovered = [] + } + const draftByLocalId = new Map(discovered.map((plugin) => [plugin.localId, plugin])) + + const rows = (await listInstalledPlugins(db)) + .flatMap((result) => (result.kind === 'ok' ? [result.plugin] : [])) + .filter((plugin) => plugin.source === 'site-local') + const rowByLocalId = new Map( + rows.map((plugin) => [plugin.id.replace(/^site\./, ''), plugin]), + ) + + // Union — a deleted folder must never hide a still-running backend. + const localIds = [...new Set([...draftByLocalId.keys(), ...rowByLocalId.keys()])].sort() + + const summaries: SitePluginSummary[] = [] + for (const localId of localIds) { + const draft = draftByLocalId.get(localId) ?? null + const row = rowByLocalId.get(localId) ?? null + const pluginId = sitePluginIdFromLocalId(localId) + const revisions = + row && uploadsDir ? await listSitePluginRevisions(uploadsDir, pluginId) : [] + + let manifest: PluginManifest | null = null + let manifestError: string | null = null + let draftContentHash: string | null = null + if (draft) { + draftContentHash = computeSitePluginContentHash(draft.files) + if (draft.manifestFile?.content) { + try { + manifest = deriveSitePluginManifest({ + localId, + draftManifestJson: draft.manifestFile.content, + files: draft.files, + previousVersion: row?.version ?? null, + contentHash: draftContentHash, + }) + } catch (err) { + manifestError = getErrorMessage(err, 'Invalid site plugin manifest') + } + } else { + manifestError = `plugins/${localId}/plugin.json is missing` + } + } + + const declared = manifest?.permissions ?? [] + const granted = row?.grantedPermissions ?? [] + + const state = computeSitePluginState({ + hasDraftSource: draft !== null, + row: row + ? { version: row.version, lifecycleStatus: row.lifecycleStatus, enabled: row.enabled } + : null, + manifestError, + draftContentHash, + activeContentHash: contentHashOfVersion(row?.version), + }) + + summaries.push({ + localId, + pluginId, + name: manifest?.name ?? row?.name ?? localId, + state, + activeVersion: row?.version ?? null, + revisions, + hasDraftSource: draft !== null, + hasModules: Boolean(manifest?.entrypoints?.modules), + declaredPermissions: declared, + grantedPermissions: granted, + newPermissions: declared.filter((permission) => !granted.includes(permission)), + removedPermissions: granted.filter((permission) => !declared.includes(permission)), + manifestError, + lastError: row?.lastError ?? null, + }) + } + return summaries +} + +// --------------------------------------------------------------------------- +// Activation engine — shared by the HTTP route and the AI `plugin_activate` +// tool. HTTP-free: consent (step-up) stays with the HTTP wrapper; callers +// that cannot prompt (AI tools, MCP) pass `allowGrantChange: false` and +// surface the `grants-changed` result as an instruction to confirm in the +// IDE header. +// --------------------------------------------------------------------------- + +export type SitePluginActivationResult = + | { + status: 'ok' + plugin: InstalledPlugin + upgrade?: { fromVersion: string; toVersion: string } + skipped: boolean + /** The activation succeeded but the coupled republish did not. */ + warning?: string + } + | { status: 'not-found'; message: string } + | { status: 'invalid'; message: string } + /** The row is deactivated; rebuilding must not silently re-enable it. */ + | { status: 'disabled'; message: string } + | { status: 'grants-changed'; newPermissions: string[]; removedPermissions: string[] } + | { status: 'build-failed'; diagnostics: string[] } + | { status: 'upgrade-error'; message: string } + +export interface SitePluginActivationInput { + db: DbClient + options: CmsHandlerOptions + user: AuthUser + /** Null for non-HTTP callers — audit rows then omit ip/ua. */ + req: Request | null + localId: string + /** + * Whether a grant-set change may proceed. The HTTP route sets this true + * only AFTER a successful step-up; tool callers always pass false. + */ + allowGrantChange: boolean +} + +export function runSitePluginActivation( + input: SitePluginActivationInput, +): Promise { + return withSitePluginLock(input.localId, () => activateUnlocked(input)) +} + +async function activateUnlocked( + input: SitePluginActivationInput, +): Promise { + const { db, options, user, req, localId, allowGrantChange } = input + if (!options.uploadsDir) { + return { status: 'invalid', message: 'Uploads directory is not configured' } + } + const draftFiles = await readDraftPluginFiles(db) + const plugin = discoverSitePlugins(draftFiles).find((entry) => entry.localId === localId) + if (!plugin) { + return { status: 'not-found', message: `No site plugin source at plugins/${localId}/` } + } + if (!plugin.manifestFile?.content) { + return { status: 'invalid', message: `plugins/${localId}/plugin.json is missing` } + } + + const rowResult = await getInstalledPlugin(db, sitePluginIdFromLocalId(localId)) + const row = rowResult?.kind === 'ok' ? rowResult.plugin : null + const contentHash = computeSitePluginContentHash(plugin.files) + + // An operator deactivated this plugin on purpose. A rebuild (the everyday + // agent loop over MCP included) must not bring it back as a side effect: + // enabling is a lifecycle action with its own capability and step-up. + if (row && (!row.enabled || row.lifecycleStatus === 'disabled')) { + return { + status: 'disabled', + message: + `Site plugin "${localId}" is deactivated. Activate it first (the Activate action ` + + 'on the Plugins page or in the IDE header), then build again.', + } + } + + // Skip when the active revision already matches the draft source. + if ( + row && + row.lifecycleStatus === 'active' && + contentHashOfVersion(row.version) === contentHash + ) { + return { status: 'ok', plugin: row, skipped: true } + } + + // Derive BEFORE building: the grant diff decides whether this request is + // a consent moment — and a manifest error should fail before any + // bundling work. + let manifest: PluginManifest + try { + manifest = deriveSitePluginManifest({ + localId, + draftManifestJson: plugin.manifestFile.content, + files: plugin.files, + previousVersion: row?.version ?? null, + contentHash, + }) + } catch (err) { + return { status: 'invalid', message: getErrorMessage(err, 'Invalid site plugin manifest') } + } + + const granted: readonly string[] = row?.grantedPermissions ?? [] + const declared: readonly string[] = manifest.permissions + const grantsChanged = !row || !sameStringSet(granted, declared) + if (grantsChanged && !allowGrantChange) { + return { + status: 'grants-changed', + newPermissions: declared.filter((p) => !granted.includes(p)), + removedPermissions: granted.filter((p) => !declared.includes(p)), + } + } + + const built = await buildSitePlugin({ + localId, + files: plugin.files, + previousVersion: row?.version ?? null, + uploadsDir: options.uploadsDir, + validateOnly: false, + }) + if (!built.ok) { + return { status: 'build-failed', diagnostics: built.diagnostics } + } + + // Grants = declared, in both directions: activation grants exactly what + // the draft declares, and dropping a permission shrinks the grant. + const outcome = await activatePluginPackageFromDisk({ + db, + options, + user, + req, + manifest: built.manifest, + grantedPermissions: built.manifest.permissions, + source: 'site-local', + }) + if (outcome.upgradeError) { + return { status: 'upgrade-error', message: outcome.upgradeError } + } + + const warning = await republishAndSweep(db, options.uploadsDir, built.manifest) + + return { + status: 'ok', + plugin: outcome.plugin, + ...(outcome.upgrade ? { upgrade: outcome.upgrade } : {}), + skipped: false, + ...(warning ? { warning } : {}), + } +} + +/** + * Publish coupling (design → "Revision/publish coupling — defined"): baked + * Layer-A HTML embeds versioned asset URLs, so when the plugin has + * visitor-facing surfaces the host republishes BEFORE any old revision is + * garbage-collected — published pages must never reference a deleted + * revision. Both artefact kinds are re-baked: pages AND entry-template + * data rows (`/posts/hello`), which a page-only republish would leave + * pointing at the swept directory. Backend-only plugins skip the republish + * and sweep immediately. + * + * Returns a warning for the caller to surface when the republish failed: + * the activation stands, the old revision stays on disk (unswept, so + * published pages keep working), and the operator has to publish to move + * visitors onto the new revision. + */ +export async function republishAndSweep( + db: DbClient, + uploadsDir: string, + manifest: PluginManifest, +): Promise { + const visitorFacing = + (manifest.frontend?.assets.length ?? 0) > 0 || Boolean(manifest.entrypoints?.modules) + if (visitorFacing) { + try { + // Under the publish lock, with the same version ordering the full + // publish uses: bake at N+1, then bump. Baking at N and bumping after + // would stamp every hole shell one version stale. + await withPublishLock(async () => { + const nextVersion = getPublishVersion() + 1 + await republishAllPages(db, uploadsDir, nextVersion) + await republishAllDataRows(db, uploadsDir, nextVersion) + bumpPublishVersion() + }) + } catch (err) { + // A failed republish must not fail the activation — but it must also + // not trigger the sweep (old revision stays referenced). + console.error(`[site-plugins] post-activation republish failed for ${manifest.id}:`, err) + return ( + `Activated, but republishing the site failed: ${getErrorMessage(err, 'unknown error')}. ` + + 'Published pages still use the previous revision until you publish the site.' + ) + } + } + await sweepSitePluginRevisions(uploadsDir, manifest.id, manifest.version) + return null +} diff --git a/server/plugins/package.ts b/server/plugins/package.ts index 1652617e9..e70187acb 100644 --- a/server/plugins/package.ts +++ b/server/plugins/package.ts @@ -1,5 +1,6 @@ import { strFromU8, unzipSync } from 'fflate' import { + isReservedSitePluginId, parsePluginManifest, } from '@core/plugins/manifest' import { assertSandboxSafe } from '@core/plugins/sandboxScan' @@ -48,6 +49,17 @@ export async function readPluginPackage(file: File): Promise { // parsePluginManifest is a TypeBox schema validator — it accepts unknown // and throws on shape mismatch. Safe boundary. const manifest = parsePluginManifest(JSON.parse(manifestText)) + + // The `site.` namespace belongs to site plugins built from the site draft. + // An uploaded zip claiming it could hijack a site plugin's runtime + // identity, grants, settings, and secrets — reject at the zip boundary. + if (isReservedSitePluginId(manifest.id)) { + throw new Error( + `Plugin id "${manifest.id}" uses the reserved "site." namespace. ` + + `That namespace belongs to site plugins built from the site draft; ` + + `uploaded packages must use a vendor namespace (e.g. "acme.${manifest.id.slice(5)}").`, + ) + } const entrypoints = [ ...Object.values(manifest.entrypoints ?? {}), ...manifest.adminPages.flatMap((page) => diff --git a/server/plugins/sitePlugins/build.ts b/server/plugins/sitePlugins/build.ts new file mode 100644 index 000000000..90c2c1b10 --- /dev/null +++ b/server/plugins/sitePlugins/build.ts @@ -0,0 +1,160 @@ +/** + * Site plugin build orchestrator — the server frontend of + * `@core/plugin-build`. + * + * draft SiteFile[] → derive manifest → materialize temp workspace + * → buildPluginPackage (fail-closed import containment, bundle timeout) + * → package under uploads/plugins/site.// (or a throwaway + * dir in validate-only mode) + * + * Builds are single-flight per localId: a second build queues behind the + * first and runs against the arguments IT was called with (the caller + * re-reads the then-current draft before calling). + */ +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import type { SiteFile } from '@core/files/schemas' +import type { PluginManifest } from '@core/plugin-sdk' +import { buildPluginPackage } from '@core/plugin-build' +import { + computeSitePluginContentHash, + deriveSitePluginManifest, + sitePluginFolder, +} from '@core/site-plugins' +import { getErrorMessage } from '@core/utils/errorMessage' +import { createKeyedSerializer } from '../../util/keyedSerial' +import { materializeSitePluginWorkspace } from './workspace' + +/** + * '@instatic/plugin-sdk' resolves to the SDK's inlinable runtime surface + * (pure data builders — defineModule, control, html, sitePluginRoute) so + * sandbox/module bundles stay self-contained AND small. Never the full + * barrel: its TypeBox-backed schema modules weigh hundreds of kB and have + * tripped bundler tree-shake edge cases when inlined. + */ +const SDK_ENTRY = resolve(import.meta.dir, '../../../src/core/plugin-sdk/inlineRuntime.ts') + +/** Pathological inputs must fail fast instead of tying up the server. */ +const DEFAULT_BUILD_TIMEOUT_MS = 30_000 + +export interface SitePluginBuildInput { + localId: string + /** Every 'plugin' file under plugins// from the persisted draft. */ + files: readonly SiteFile[] + /** The currently active generated version, or null on first build. */ + previousVersion: string | null + /** Uploads root — required unless `validateOnly`. */ + uploadsDir?: string + /** true = diagnostics only: build into the throwaway workspace, never under uploads. */ + validateOnly: boolean + /** Override the build timeout (ms). Mainly for tests. */ + buildTimeoutMs?: number +} + +export interface SitePluginBuildSuccess { + ok: true + manifest: PluginManifest + contentHash: string + /** Absolute package dir (absent in validate-only mode). */ + packageDir?: string + /** Built modules bundle text (validate-only, when the draft declares one) — feeds `Preview in canvas`. */ + modulesBundle?: string +} + +export interface SitePluginBuildFailure { + ok: false + diagnostics: string[] +} + +export type SitePluginBuildResult = SitePluginBuildSuccess | SitePluginBuildFailure + +// Single-flight per localId — later builds chain behind earlier ones. +const serializeBuild = createKeyedSerializer() + +export function buildSitePlugin(input: SitePluginBuildInput): Promise { + return serializeBuild(input.localId, () => runBuild(input)) +} + +async function runBuild(input: SitePluginBuildInput): Promise { + const contentHash = computeSitePluginContentHash(input.files) + + const manifestFile = input.files.find( + (f) => f.path === `${sitePluginFolder(input.localId)}plugin.json`, + ) + if (!manifestFile || typeof manifestFile.content !== 'string') { + return { ok: false, diagnostics: [`plugins/${input.localId}/plugin.json is missing`] } + } + + let manifest: PluginManifest + try { + manifest = deriveSitePluginManifest({ + localId: input.localId, + draftManifestJson: manifestFile.content, + files: input.files, + previousVersion: input.previousVersion, + contentHash, + }) + } catch (err) { + return { ok: false, diagnostics: [getErrorMessage(err, 'Invalid site plugin manifest')] } + } + + if (!input.validateOnly && !input.uploadsDir) { + return { ok: false, diagnostics: ['Uploads directory is not configured'] } + } + + const workspace = await materializeSitePluginWorkspace(input.localId, input.files) + try { + const outputDir = input.validateOnly + ? join(workspace.rootDir, '.instatic-dist') + : join(input.uploadsDir!, 'plugins', manifest.id, manifest.version) + + const timeoutMs = input.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS + let timeoutHandle: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error(`site plugin build timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + }) + try { + await Promise.race([ + buildPluginPackage({ + sourceDir: workspace.rootDir, + outputDir, + manifest, + resolve: { + workspaceRoot: workspace.rootDir, + bareSpecifiers: { '@instatic/plugin-sdk': SDK_ENTRY }, + }, + }), + timeout, + ]) + } finally { + clearTimeout(timeoutHandle) + } + + let modulesBundle: string | undefined + if (input.validateOnly && manifest.entrypoints?.modules) { + // Read before cleanup — the throwaway workspace is about to vanish. + modulesBundle = await readFile(join(outputDir, manifest.entrypoints.modules), 'utf8') + } + + return { + ok: true, + manifest, + contentHash, + ...(input.validateOnly ? {} : { packageDir: outputDir }), + ...(modulesBundle !== undefined ? { modulesBundle } : {}), + } + } catch (err) { + // Diagnostics quote bundler paths inside the throwaway workspace — + // rewrite them to the draft's plugins// paths so the message + // names the author's file instead of leaking a server temp dir. + const message = getErrorMessage(err, 'Site plugin build failed') + .replaceAll(`${workspace.rootDir}/`, sitePluginFolder(input.localId)) + .replaceAll(workspace.rootDir, sitePluginFolder(input.localId)) + return { ok: false, diagnostics: [message] } + } finally { + await workspace.cleanup() + } +} diff --git a/server/plugins/sitePlugins/retention.ts b/server/plugins/sitePlugins/retention.ts new file mode 100644 index 000000000..f226210c1 --- /dev/null +++ b/server/plugins/sitePlugins/retention.ts @@ -0,0 +1,92 @@ +/** + * Site plugin revision retention — generated packages under + * `uploads/plugins/site.//` accumulate one dir per build. + * Policy (design: docs/features/site-plugins.md → "Cleanup And Retention"): + * + * - keep the RETAINED_REVISIONS highest builds, plus whatever is active + * (a rollback can make an older build the active one); + * - every retained build is a rollback target — the IDE's version picker + * lists them. Source rolls forward only, so the artifact is the only + * rollback; + * - delete the rest AFTER activation and the coupled republish (when + * required) succeed — callers sequence that; this module only sweeps. + * + * Whole-plugin teardown (uninstall) rides the existing + * `removeAllPluginAssets` sweep, not this module. + */ +import { readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { assertPathWithin } from '../../util/pathWithin' + +export const RETAINED_REVISIONS = 5 + +const VERSION_COUNTER = /^1\.0\.(\d+)\+/ + +export interface SitePluginRevision { + version: string + /** Build time, epoch ms — the package directory's mtime. */ + builtAt: number +} + +function counterOf(version: string): number { + const match = VERSION_COUNTER.exec(version) + return match ? Number(match[1]) : -1 +} + +/** Directory entries of the plugin's revision tree; versions newest first. */ +async function revisionEntries( + uploadsDir: string, + pluginId: string, +): Promise<{ pluginDir: string; entries: string[]; versions: string[] }> { + const pluginDir = join(uploadsDir, 'plugins', pluginId) + assertPathWithin(uploadsDir, pluginDir) + let entries: string[] + try { + entries = await readdir(pluginDir) + } catch { + entries = [] // nothing built yet + } + const versions = entries + .filter((entry) => counterOf(entry) >= 0) + .sort((a, b) => counterOf(b) - counterOf(a)) + return { pluginDir, entries, versions } +} + +/** Retained builds, newest first — the rollback targets. */ +export async function listSitePluginRevisions( + uploadsDir: string, + pluginId: string, +): Promise { + const { pluginDir, versions } = await revisionEntries(uploadsDir, pluginId) + const revisions: SitePluginRevision[] = [] + for (const version of versions) { + const dir = join(pluginDir, version) + assertPathWithin(uploadsDir, dir) + const info = await stat(dir) + revisions.push({ version, builtAt: Math.round(info.mtimeMs) }) + } + return revisions +} + +/** + * Keep the RETAINED_REVISIONS highest builds plus the active one; delete + * everything else (including entries that are not version-shaped). + */ +export async function sweepSitePluginRevisions( + uploadsDir: string, + pluginId: string, + activeVersion: string, +): Promise { + const { pluginDir, entries, versions } = await revisionEntries(uploadsDir, pluginId) + const keep = new Set([activeVersion, ...versions.slice(0, RETAINED_REVISIONS)]) + + const removed: string[] = [] + for (const entry of entries) { + if (keep.has(entry)) continue + const target = join(pluginDir, entry) + assertPathWithin(uploadsDir, target) + await rm(target, { recursive: true, force: true }) + removed.push(entry) + } + return removed +} diff --git a/server/plugins/sitePlugins/workspace.ts b/server/plugins/sitePlugins/workspace.ts new file mode 100644 index 000000000..1568070fb --- /dev/null +++ b/server/plugins/sitePlugins/workspace.ts @@ -0,0 +1,44 @@ +/** + * Site plugin workspace materializer — writes one plugin's draft files into + * an isolated temp directory (prefix stripped) so the shared builder core + * can bundle them. Same pattern as `materializeSiteScriptWorkspace` + * (server/publish/runtime/virtualSiteWorkspace.ts): path-safety checks, + * realpath'd temp root, cleanup callback. + */ +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import type { SiteFile } from '@core/files/schemas' +import { isSafePath, normalizePath } from '@core/files/pathValidation' +import { sitePluginFolder } from '@core/site-plugins' + +export interface SitePluginWorkspace { + rootDir: string + cleanup: () => Promise +} + +/** Write plugins//** files into a temp dir, prefix stripped. */ +export async function materializeSitePluginWorkspace( + localId: string, + files: readonly SiteFile[], +): Promise { + const tempDir = await mkdtemp(join(tmpdir(), `instatic-site-plugin-${localId}-`)) + const rootDir = await realpath(tempDir) + const prefix = sitePluginFolder(localId) + try { + for (const file of files) { + if (typeof file.content !== 'string') continue + if (!file.path.startsWith(prefix)) continue + const relative = normalizePath(file.path.slice(prefix.length)) + if (!relative || !isSafePath(relative)) continue + const absolutePath = resolve(rootDir, relative) + if (!absolutePath.startsWith(rootDir + '/')) continue + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, file.content, 'utf8') + } + } catch (error) { + await rm(rootDir, { recursive: true, force: true }) + throw error + } + return { rootDir, cleanup: () => rm(rootDir, { recursive: true, force: true }) } +} diff --git a/server/publish/bakeDataRows.ts b/server/publish/bakeDataRows.ts index 146c96393..c96d1907e 100644 --- a/server/publish/bakeDataRows.ts +++ b/server/publish/bakeDataRows.ts @@ -30,10 +30,24 @@ import { } from '../repositories/data/publish' import { renderPublishedDataRowTemplate } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -import { writeArtefact } from './staticArtefact' import { getLatestSnapshotForVersion } from './publishedSnapshotCache' import { snapshotForEntryRoute } from './entryTemplateSnapshot' +export interface DataRowBakeTarget { + /** + * The publish version the baked shells must carry — the NEXT version for + * a full publish (the bake runs before `bumpPublishVersion()`), and + * likewise for an in-place republish that bumps right after. + */ + publishVersion: number + /** + * Where each route's HTML goes: `writeArtefact` into the inactive slot + * for a full publish, `updateArtefactInPlace` into the active slot for a + * republish. The render path is identical either way. + */ + write: (urlPath: string, html: string) => Promise +} + interface DataRowBakeResult { /** Routes successfully baked into the slot. */ baked: number @@ -51,20 +65,22 @@ function publicRowPath(routeBase: string, slug: string): string { } /** - * Bake every published data-row route into `slotDir`. Called by the full - * publish AFTER its transaction commits (the row list and snapshot reads see - * the freshly-committed publish) and BEFORE the slot swap. + * Bake every published data-row route through `target.write`. The full + * publish calls this AFTER its transaction commits (the row list and + * snapshot reads see the freshly-committed publish) and BEFORE the slot + * swap; a site plugin activation calls it to refresh the active slot in + * place. * - * `publishVersion` is the NEXT publish version — the bake runs before - * `bumpPublishVersion()`, so baked hole shells must carry the version that - * becomes current at the swap. Passing it to the versioned snapshot memo - * also pre-warms the cache visitors are about to read. + * `target.publishVersion` is the version that becomes current at the + * caller's swap or bump, so baked hole shells are never stamped stale. + * Passing it to the versioned snapshot memo also pre-warms the cache + * visitors are about to read. */ export async function bakePublishedDataRowArtefacts( db: DbClient, - slotDir: string, - publishVersion: number, + target: DataRowBakeTarget, ): Promise { + const { publishVersion } = target const result: DataRowBakeResult = { baked: 0, cssBundles: [] } const routes = await listPublishedRowRoutes(db) @@ -102,7 +118,7 @@ export async function bakePublishedDataRowArtefacts( }) if (!rendered) continue const html = await applyPublishedHtmlPipeline(rendered, db) - await writeArtefact(slotDir, urlPath, html) + await target.write(urlPath, html) result.cssBundles.push(rendered.cssBundle) result.baked++ } catch (err) { diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 913b40ca0..09bdaec0a 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -294,7 +294,10 @@ async function publishDraftSiteLocked( // template bakes into the same slot. Without this the slot swap would // strand every previously-baked row artefact in the inactive slot and // ALL row routes would fall to the live renderer after a full publish. - const rowBake = await bakePublishedDataRowArtefacts(db, slotDir, nextPublishVersion) + const rowBake = await bakePublishedDataRowArtefacts(db, { + publishVersion: nextPublishVersion, + write: (urlPath, html) => writeArtefact(slotDir, urlPath, html), + }) for (const cssBundle of rowBake.cssBundles) collectCssFiles(cssBundle) for (const [publicPath, bytes] of assetsByPath) { diff --git a/server/publish/republish.ts b/server/publish/republish.ts index 8d429b27c..261c3caeb 100644 --- a/server/publish/republish.ts +++ b/server/publish/republish.ts @@ -17,8 +17,10 @@ import type { DbClient } from '../db/client' import { getPublishedPageSnapshotById } from '../repositories/publish' +import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { renderPublishedSnapshot } from './publicRenderer' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' +import { updateArtefactInPlace } from './staticArtefact' // --------------------------------------------------------------------------- // Typed error — callers can distinguish "page not found / not published" from @@ -47,7 +49,12 @@ class PageNotPublishedError extends Error { * Throws `PageNotPublishedError` if the page is not found or is not * currently published. */ -async function republishSinglePage(db: DbClient, pageId: string): Promise { +async function republishSinglePage( + db: DbClient, + pageId: string, + uploadsDir?: string, + publishVersion?: number, +): Promise { // Typed read through the publish repository — the snapshot column is parsed // by the DbClient (`*_json` auto-parse) and typed as `PublishedPageSnapshot`, // so there is no boundary cast here. @@ -62,21 +69,41 @@ async function republishSinglePage(db: DbClient, pageId: string): Promise const syntheticUrl = new URL('http://localhost/__republish') // Drive the full pipeline (publish.before → frontend.assets injection → - // publish.html filter → publish.after). The returned HTML is discarded — - // the side-effects are what the caller actually needs (lets plugins - // catch up on pages published before they were activated). - const rendered = await renderPublishedSnapshot(snapshot, { db, url: syntheticUrl }) - await applyPublishedHtmlPipeline(rendered, db) + // publish.html filter → publish.after) — plugin hook listeners and + // filters catch up on pages published before the plugin was activated. + const rendered = await renderPublishedSnapshot(snapshot, { + db, + url: syntheticUrl, + ...(publishVersion !== undefined ? { publishVersion } : {}), + }) + const html = await applyPublishedHtmlPipeline(rendered, db) + + // Re-bake the Layer-A static artefact when the caller owns an uploads dir. + // Without this, baked pages keep serving HTML rendered against the OLD + // module registrations / versioned asset URLs — the exact staleness the + // site plugin activation coupling exists to prevent. + if (uploadsDir) { + const urlPath = rendered.slug === 'index' ? '/' : `/${rendered.slug}` + await updateArtefactInPlace(uploadsDir, urlPath, html) + } } /** * Republish every currently-published page. Iterates all published pages and * calls `republishSinglePage` for each. Returns the total count published. * + * `publishVersion` is the version the re-baked shells must carry when the + * caller bumps the counter right after (site plugin activation does; the + * plugin-host `republishAll` does not and leaves it unset). + * * Errors for individual pages are logged and do not abort the batch — the * count reflects pages that completed without error. */ -export async function republishAllPages(db: DbClient): Promise { +export async function republishAllPages( + db: DbClient, + uploadsDir?: string, + publishVersion?: number, +): Promise { const { rows } = await db<{ id: string }>` select id from data_rows @@ -86,7 +113,9 @@ export async function republishAllPages(db: DbClient): Promise { and deleted_at is null order by created_at asc ` - const results = await Promise.allSettled(rows.map(row => republishSinglePage(db, row.id))) + const results = await Promise.allSettled( + rows.map((row) => republishSinglePage(db, row.id, uploadsDir, publishVersion)), + ) let count = 0 for (const [i, result] of results.entries()) { if (result.status === 'fulfilled') { @@ -97,3 +126,22 @@ export async function republishAllPages(db: DbClient): Promise { } return count } + +/** + * Re-bake every published data-row route (entry-template pages such as + * `/posts/hello`) into the ACTIVE slot in place. The page republish above + * never touches these — they are produced by the data-row bake — so a site + * plugin activation that changes visitor-facing assets must run both before + * the old revision is swept. Returns the number of routes re-baked. + */ +export async function republishAllDataRows( + db: DbClient, + uploadsDir: string, + publishVersion: number, +): Promise { + const result = await bakePublishedDataRowArtefacts(db, { + publishVersion, + write: (urlPath, html) => updateArtefactInPlace(uploadsDir, urlPath, html), + }) + return result.baked +} diff --git a/server/repositories/plugins.ts b/server/repositories/plugins.ts index a150666df..2feda1d61 100644 --- a/server/repositories/plugins.ts +++ b/server/repositories/plugins.ts @@ -1,5 +1,6 @@ import type { InstalledPlugin, + InstalledPluginSource, PluginLifecycleStatus, PluginManifest, PluginPermission, @@ -44,6 +45,7 @@ interface InstalledPluginRow { granted_permissions_json?: unknown manifest_json: unknown settings_json?: unknown + source?: string | null installed_at: Date | string updated_at: Date | string } @@ -94,6 +96,7 @@ function mapInstalledPlugin(row: InstalledPluginRow): InstalledPluginResult { grantedPermissions: Array.isArray(grantedPermissions) ? grantedPermissions as PluginPermission[] : manifest.grantedPermissions ?? [], + source: row.source === 'site-local' ? 'site-local' : 'installed', manifest, settings, installedAt: isoDate(row.installed_at), @@ -171,7 +174,7 @@ function mapPluginRecord(row: PluginRecordRow): PluginRecord { export async function listInstalledPlugins(db: DbClient): Promise { const { rows } = await db` select id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at from installed_plugins order by installed_at desc ` @@ -181,7 +184,7 @@ export async function listInstalledPlugins(db: DbClient): Promise { const { rows } = await db` select id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at from installed_plugins where id = ${id} ` @@ -192,7 +195,9 @@ export async function installPlugin( db: DbClient, manifest: PluginManifest, grantedPermissions: PluginPermission[] = manifest.grantedPermissions ?? [], + opts: { source?: InstalledPluginSource } = {}, ): Promise { + const source: InstalledPluginSource = opts.source ?? 'installed' const manifestToStore = { ...manifest, grantedPermissions } const declared = manifest.settings ?? [] // Seed settings with the manifest's declared defaults so plugins reading @@ -204,8 +209,8 @@ export async function installPlugin( Object.entries(pluginSettingsDefaults(declared)).filter(([key]) => !secretIds.has(key)), ) const { rows } = await db` - insert into installed_plugins (id, name, version, manifest_json, granted_permissions_json, settings_json, enabled, lifecycle_status, last_error) - values (${manifest.id}, ${manifest.name}, ${manifest.version}, ${writeJson(manifestToStore)}, ${writeJson(grantedPermissions)}, ${writeJson(initialSettings)}, true, 'installed', null) + insert into installed_plugins (id, name, version, manifest_json, granted_permissions_json, settings_json, enabled, lifecycle_status, last_error, source) + values (${manifest.id}, ${manifest.name}, ${manifest.version}, ${writeJson(manifestToStore)}, ${writeJson(grantedPermissions)}, ${writeJson(initialSettings)}, true, 'installed', null, ${source}) on conflict (id) do update set name = excluded.name, version = excluded.version, @@ -214,9 +219,10 @@ export async function installPlugin( enabled = true, lifecycle_status = 'installed', last_error = null, + source = excluded.source, updated_at = ${nowIso()} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` // Secret settings with a non-empty manifest default get an encrypted row. // Insert-if-absent: the upgrade/rollback flows reuse this upsert and must @@ -240,7 +246,7 @@ export async function setPluginEnabled( update installed_plugins set enabled = ${enabled}, updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } @@ -255,7 +261,7 @@ export async function setPluginLifecycleStatus( update installed_plugins set lifecycle_status = ${lifecycleStatus}, last_error = ${lastError}, updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } @@ -287,7 +293,7 @@ export async function setPluginSettings( updated_at = ${nowIso()} where id = ${id} returning id, name, version, enabled, lifecycle_status, last_error, - granted_permissions_json, manifest_json, settings_json, installed_at, updated_at + granted_permissions_json, manifest_json, settings_json, source, installed_at, updated_at ` return rows[0] ? mapInstalledPlugin(rows[0]) : null } diff --git a/server/router.ts b/server/router.ts index 5da4d651c..8bab70245 100644 --- a/server/router.ts +++ b/server/router.ts @@ -155,7 +155,7 @@ function tryServeMcpOAuth( * by the CMS dispatcher. */ function tryServeAi(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise | null { - return tryHandleAi(req, runtime.db, url) + return tryHandleAi(req, runtime.db, url, { uploadsDir: runtime.uploadsDir }) } /** diff --git a/server/util/keyedSerial.ts b/server/util/keyedSerial.ts new file mode 100644 index 000000000..0749bf34a --- /dev/null +++ b/server/util/keyedSerial.ts @@ -0,0 +1,27 @@ +/** + * Per-key promise-chain serializer. Calls for the same key run one after + * another in arrival order; calls for different keys run concurrently. A + * rejected call does not block the ones queued behind it. JS is + * single-threaded, so a promise chain is a sufficient lock — the same + * shape `withPublishLock` uses for the one global publish key. + * + * Used to serialize site plugin builds and lifecycle transitions per plugin + * id: two overlapping activations must not both read the same row version, + * derive the same next version, and race the upgrade path. + */ +export type KeyedSerializer = (key: string, fn: () => Promise) => Promise + +export function createKeyedSerializer(): KeyedSerializer { + const chains = new Map>() + return (key: string, fn: () => Promise): Promise => { + const previous = chains.get(key) ?? Promise.resolve() + const run = (): Promise => fn() + const next = previous.then(run, run) + chains.set(key, next) + const release = (): void => { + if (chains.get(key) === next) chains.delete(key) + } + next.then(release, release) + return next + } +} diff --git a/server/writePolicy/siteDiff.ts b/server/writePolicy/siteDiff.ts index 0f2c6fc63..c17ad9836 100644 --- a/server/writePolicy/siteDiff.ts +++ b/server/writePolicy/siteDiff.ts @@ -17,6 +17,9 @@ * the "client / copy editor" persona owns). * style — classes registry contents, settings.framework, settings.fonts, * file contents. + * plugins — any change to a `type: 'plugin'` file (site-plugin source + * authored in the Plugin IDE). Plugin code is a different trust + * level than site structure, so it has its own capability. * * Pages are NOT diffed here (managed by /pages endpoint). * Visual Components are NOT diffed here (managed as data_rows via /components @@ -26,6 +29,7 @@ * site.structure.edit → structure * site.content.edit → content * site.style.edit → style + * plugins.edit → plugins * * First-save semantics: when there is no previous shell (`previous === null`), * the incoming document is treated as a structural change in its entirety — @@ -38,7 +42,7 @@ import type { } from '@core/page-tree' import { deepEqual } from '@core/utils/deepEqual' -type SiteChangeKind = 'structure' | 'content' | 'style' +type SiteChangeKind = 'structure' | 'content' | 'style' | 'plugins' export class ForbiddenSiteChangeError extends Error { // The TS `erasableSyntaxOnly` lint forbids constructor-parameter properties, @@ -60,6 +64,7 @@ const CAP_FOR_KIND: Record = { structure: 'site.structure.edit', content: 'site.content.edit', style: 'site.style.edit', + plugins: 'plugins.edit', } interface DiffContext { @@ -91,11 +96,14 @@ export function validateSiteWriteDiff( capabilities: readonly CoreCapability[], ): void { // Fast path: a caller with the full set never needs the diff — they can - // make any change. Saves cycles on the common case. + // make any change. Saves cycles on the common case. `plugins.edit` is part + // of the full set: without it, a full site-writer must still be diffed so + // plugin-source changes are caught. if ( capabilities.includes('site.structure.edit') && capabilities.includes('site.content.edit') && - capabilities.includes('site.style.edit') + capabilities.includes('site.style.edit') && + capabilities.includes('plugins.edit') ) { return } @@ -226,6 +234,18 @@ function diffFiles( for (const id of new Set([...prevById.keys(), ...nextById.keys()])) { const a = prevById.get(id) const b = nextById.get(id) + // Site-plugin source (type 'plugin') is its own trust domain: every + // change to it — add, remove, rename, retype, content — requires + // plugins.edit, never a site capability. Retyping across the boundary + // counts as a plugin change from either side. + if (a?.type === 'plugin' || b?.type === 'plugin') { + const changed = + !a || !b || a.path !== b.path || a.type !== b.type || a.content !== b.content + if (changed) { + requireChange(ctx, 'plugins', `files.${id}`, 'plugin source changed') + } + continue + } if (!a || !b) { requireChange(ctx, 'structure', `files.${id}`, a ? 'removed' : 'added') continue diff --git a/smoke-test/.gitignore b/smoke-test/.gitignore new file mode 100644 index 000000000..df45c0b07 --- /dev/null +++ b/smoke-test/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.plugin.zip +.DS_Store diff --git a/smoke-test/README.md b/smoke-test/README.md new file mode 100644 index 000000000..434a56c71 --- /dev/null +++ b/smoke-test/README.md @@ -0,0 +1,23 @@ +# Smoke Test + +> Plugin id: `local.smoke-test` + +## Develop + +```bash +instatic-plugin dev # watch + sync into the running CMS +instatic-plugin build # produce a .plugin.zip +``` + +The dev command writes built files directly into the host CMS's +`uploads/plugins/local.smoke-test//` directory. On first run it +auto-detects the host's `uploads/` folder by walking up from the plugin +directory; pass `--uploads ` (or set `INSTATIC_UPLOADS_DIR`) when running +outside the instatic monorepo. + +You'll need to install the plugin once via the admin UI (`/admin/plugins` → +Upload Plugin) so the host registers it and approves permissions. After +that, every `instatic-plugin dev` rebuild flows in without another upload. + +See [docs/features/plugin-system.md](../instatic/docs/features/plugin-system.md) +for the full plugin SDK surface. diff --git a/smoke-test/instatic-plugin.config.ts b/smoke-test/instatic-plugin.config.ts new file mode 100644 index 000000000..38fb3fb64 --- /dev/null +++ b/smoke-test/instatic-plugin.config.ts @@ -0,0 +1,14 @@ +import { definePlugin, permissions } from '@core/plugin-sdk' +import hello from './modules/hello' + +export default definePlugin({ + id: 'local.smoke-test', + name: 'Smoke Test', + version: '0.1.0', + description: 'A new Smoke Test plugin.', + permissions: [permissions.modulesRegister], + modules: [hello], + // Add settings, admin pages, hooks, frontend bundles, or a Visual Component + // pack here as your plugin grows. See docs/features/plugin-system.md for the + // full SDK surface. +}) diff --git a/smoke-test/modules/hello.ts b/smoke-test/modules/hello.ts new file mode 100644 index 000000000..b8b4fcb1e --- /dev/null +++ b/smoke-test/modules/hello.ts @@ -0,0 +1,19 @@ +import { control, defineModule, html } from '@core/plugin-sdk' + +export default defineModule({ + id: 'local.smoke-test.hello', + name: 'Hello', + description: 'Sample canvas module emitted by the scaffolded plugin.', + category: 'Smoke Test', + htmlTag: 'div', + defaults: { + message: 'Hello from your new plugin.', + }, + schema: { + message: control.text('Message'), + }, + render: ({ props }) => ({ + html: html`
${props.message}
`, + css: `.hello { padding: 12px; border: 1px dashed currentColor; border-radius: 6px; }`, + }), +}) diff --git a/src/__tests__/agent/pluginBridge.test.ts b/src/__tests__/agent/pluginBridge.test.ts new file mode 100644 index 000000000..da2442c0e --- /dev/null +++ b/src/__tests__/agent/pluginBridge.test.ts @@ -0,0 +1,279 @@ +/** + * Plugin-scope browser bridge — the dispatcher that turns `plugin_*` tool + * requests into operations on the live IDE session. + * + * Runs against a Y.Text-backed fake of IdeCollabSession + a registered + * bridge handle, so it exercises the real path/hash/patch logic without a + * socket. Invariants: paths never escape the plugin folder, plugin.json is + * rename/delete-protected, patches are hash-guarded, and writes without + * plugins.edit are refused. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as Y from 'yjs' +import { executePluginTool } from '../../admin/pages/plugins/ide/agent/pluginBridge' +import { + setPluginIdeBridgeHandle, + type PluginIdeBridgeHandle, +} from '../../admin/pages/plugins/ide/agent/pluginBridgeHandle' +import type { IdeCollabSession, IdeFileMeta } from '../../admin/pages/plugins/ide/ideCollab' + +const FOLDER = 'plugins/probe/' + +interface FakeIde { + session: IdeCollabSession + handle: PluginIdeBridgeHandle + metas: IdeFileMeta[] + contentOf(fileId: string): string + selected: string[] + canEdit: boolean + synced: boolean +} + +function createFakeIde(seed: Record): FakeIde { + const doc = new Y.Doc() + const texts = new Map() + const metas: IdeFileMeta[] = [] + let counter = 0 + + const addFile = (path: string, content: string): string => { + const id = `f${++counter}` + const text = new Y.Text() + doc.getMap('files').set(id, text as unknown as Y.Map) + text.insert(0, content) + texts.set(id, text) + metas.push({ id, path, updatedAt: 0 }) + return id + } + for (const [relative, content] of Object.entries(seed)) { + addFile(`${FOLDER}${relative}`, content) + } + + const state: { selected: string[]; canEdit: boolean; synced: boolean } = { + selected: [], + canEdit: true, + synced: true, + } + + const session = { + synced: () => state.synced, + contentText: (fileId: string) => texts.get(fileId) ?? null, + createFile: (path: string, content?: string) => addFile(path, content ?? ''), + renameFile: (fileId: string, nextPath: string) => { + const meta = metas.find((entry) => entry.id === fileId) + if (meta) meta.path = nextPath + }, + deleteFile: (fileId: string) => { + const index = metas.findIndex((entry) => entry.id === fileId) + if (index >= 0) metas.splice(index, 1) + texts.delete(fileId) + }, + replaceFileContent: (fileId: string, content: string) => { + const text = texts.get(fileId) + if (!text) return + text.delete(0, text.length) + text.insert(0, content) + }, + } as unknown as IdeCollabSession + + const handle: PluginIdeBridgeHandle = { + localId: 'probe', + buildSnapshot: () => { + throw new Error('not used by the dispatcher') + }, + session: () => session, + files: () => metas, + selectFile: (fileId) => state.selected.push(fileId), + canEdit: () => state.canEdit, + } + + return { + session, + handle, + metas, + contentOf: (fileId) => texts.get(fileId)?.toString() ?? '', + get selected() { + return state.selected + }, + set canEdit(value: boolean) { + state.canEdit = value + }, + get canEdit() { + return state.canEdit + }, + set synced(value: boolean) { + state.synced = value + }, + get synced() { + return state.synced + }, + } +} + +let ide: FakeIde + +beforeEach(() => { + ide = createFakeIde({ + 'plugin.json': '{\n "name": "Probe"\n}\n', + 'server/index.ts': 'export const handler = () => new Response("v1")\n', + }) + setPluginIdeBridgeHandle(ide.handle) +}) + +afterEach(() => { + setPluginIdeBridgeHandle(null) +}) + +async function readHash(path: string): Promise { + const read = await executePluginTool('plugin_read_file', { path }) + expect(read.ok).toBe(true) + return (read.data as { hash: string }).hash +} + +describe('plugin bridge dispatcher', () => { + test('list + read return relative paths and stable hashes', async () => { + const list = await executePluginTool('plugin_list_files', {}) + expect(list.ok).toBe(true) + const files = (list.data as { files: Array<{ path: string; hash: string }> }).files + expect(files.map((file) => file.path).sort()).toEqual(['plugin.json', 'server/index.ts']) + + const read = await executePluginTool('plugin_read_file', { path: 'server/index.ts' }) + expect(read.ok).toBe(true) + const data = read.data as { content: string; hash: string; pageInfo: { nextPart: number | null } } + expect(data.content).toContain('v1') + expect(data.pageInfo.nextPart).toBeNull() + expect(files.find((file) => file.path === 'server/index.ts')?.hash).toBe(data.hash) + }) + + test('write creates new files and overwrites existing ones', async () => { + const created = await executePluginTool('plugin_write_file', { + path: 'editor/index.ts', + content: 'export {}\n', + }) + expect(created.ok).toBe(true) + expect((created.data as { created: boolean }).created).toBe(true) + expect(ide.metas.some((meta) => meta.path === `${FOLDER}editor/index.ts`)).toBe(true) + + const overwritten = await executePluginTool('plugin_write_file', { + path: 'editor/index.ts', + content: 'export const x = 1\n', + }) + expect(overwritten.ok).toBe(true) + expect((overwritten.data as { created: boolean }).created).toBe(false) + }) + + test('patch applies exact replacements behind the hash guard', async () => { + const hash = await readHash('server/index.ts') + const patched = await executePluginTool('plugin_patch_file', { + path: 'server/index.ts', + expectedHash: hash, + replacements: [{ oldText: '"v1"', newText: '"v2"' }], + }) + expect(patched.ok).toBe(true) + const meta = ide.metas.find((entry) => entry.path === `${FOLDER}server/index.ts`)! + expect(ide.contentOf(meta.id)).toContain('v2') + + // The old hash is now stale — a second patch with it must refuse. + const stale = await executePluginTool('plugin_patch_file', { + path: 'server/index.ts', + expectedHash: hash, + replacements: [{ oldText: '"v2"', newText: '"v3"' }], + }) + expect(stale.ok).toBe(false) + expect(stale.error).toContain('Hash mismatch') + }) + + test('patch inserts replacement text verbatim — dollar patterns are never interpreted', async () => { + await executePluginTool('plugin_write_file', { + path: 'price.ts', + content: 'const p = html`

${props.price}

`\n', + }) + const hash = await readHash('price.ts') + const patched = await executePluginTool('plugin_patch_file', { + path: 'price.ts', + expectedHash: hash, + replacements: [{ oldText: '${props.price}', newText: '$${props.price}' }], + }) + expect(patched.ok).toBe(true) + const meta = ide.metas.find((entry) => entry.path === `${FOLDER}price.ts`)! + expect(ide.contentOf(meta.id)).toBe('const p = html`

$${props.price}

`\n') + }) + + test('every tool waits for the initial sync', async () => { + ide.synced = false + const read = await executePluginTool('plugin_read_file', { path: 'plugin.json' }) + expect(read.ok).toBe(false) + if (!read.ok) expect(read.error).toContain('still connecting') + const write = await executePluginTool('plugin_write_file', { path: 'early.ts', content: '' }) + expect(write.ok).toBe(false) + expect(ide.metas.some((entry) => entry.path === `${FOLDER}early.ts`)).toBe(false) + }) + + test('ambiguous replacements require replaceAll', async () => { + await executePluginTool('plugin_write_file', { path: 'notes.ts', content: 'aa aa\n' }) + const hash = await readHash('notes.ts') + const ambiguous = await executePluginTool('plugin_patch_file', { + path: 'notes.ts', + expectedHash: hash, + replacements: [{ oldText: 'aa', newText: 'bb' }], + }) + expect(ambiguous.ok).toBe(false) + expect(ambiguous.error).toContain('replaceAll') + + const all = await executePluginTool('plugin_patch_file', { + path: 'notes.ts', + expectedHash: hash, + replacements: [{ oldText: 'aa', newText: 'bb', replaceAll: true }], + }) + expect(all.ok).toBe(true) + }) + + test('paths cannot escape the plugin folder', async () => { + for (const path of ['../evil.ts', '/etc/passwd', 'a/../../evil.ts']) { + const result = await executePluginTool('plugin_write_file', { path, content: 'x' }) + expect(result.ok, `path "${path}" must be rejected`).toBe(false) + } + }) + + test('plugin.json is rename- and delete-protected', async () => { + const del = await executePluginTool('plugin_delete_file', { path: 'plugin.json' }) + expect(del.ok).toBe(false) + expect(del.error).toContain('manifest') + + const rename = await executePluginTool('plugin_rename_file', { + path: 'plugin.json', + newPath: 'manifest.json', + }) + expect(rename.ok).toBe(false) + + // Ordinary files rename + delete fine. + const ok = await executePluginTool('plugin_rename_file', { + path: 'server/index.ts', + newPath: 'server/main.ts', + }) + expect(ok.ok).toBe(true) + const gone = await executePluginTool('plugin_delete_file', { path: 'server/main.ts' }) + expect(gone.ok).toBe(true) + }) + + test('open_file switches the visible buffer', async () => { + const result = await executePluginTool('plugin_open_file', { path: 'plugin.json' }) + expect(result.ok).toBe(true) + expect(ide.selected).toHaveLength(1) + }) + + test('writes are refused without plugins.edit; reads still work', async () => { + ide.canEdit = false + const write = await executePluginTool('plugin_write_file', { path: 'x.ts', content: '' }) + expect(write.ok).toBe(false) + expect(write.error).toContain('plugins.edit') + const read = await executePluginTool('plugin_read_file', { path: 'plugin.json' }) + expect(read.ok).toBe(true) + }) + + test('a closed IDE yields an actionable error', async () => { + setPluginIdeBridgeHandle(null) + const result = await executePluginTool('plugin_list_files', {}) + expect(result.ok).toBe(false) + expect(result.error).toContain('/admin/plugins/develop') + }) +}) diff --git a/src/__tests__/ai/textReplacements.test.ts b/src/__tests__/ai/textReplacements.test.ts new file mode 100644 index 000000000..756d62059 --- /dev/null +++ b/src/__tests__/ai/textReplacements.test.ts @@ -0,0 +1,57 @@ +/** + * Exact-text replacement engine shared by the code-asset and plugin-file + * patch tools. The one property that matters most: replacement text is + * inserted VERBATIM — `$$`, `$&` and friends are never interpreted the way + * `String.prototype.replace` would. + */ +import { describe, expect, test } from 'bun:test' +import { applyExactReplacements, countOccurrences } from '@core/ai' + +describe('applyExactReplacements', () => { + test('inserts replacement text verbatim, including dollar patterns', () => { + const source = 'const price = html`

${props.price}

`' + const result = applyExactReplacements(source, [ + { oldText: '${props.price}', newText: '$${props.price}' }, + ]) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.content).toBe('const price = html`

$${props.price}

`') + expect(result.replaced).toBe(1) + + const special = applyExactReplacements('a-b', [{ oldText: '-', newText: "$& $' $` $$ $1" }]) + expect(special.ok).toBe(true) + if (special.ok) expect(special.content).toBe("a$& $' $` $$ $1b") + }) + + test('replaceAll substitutes every occurrence and counts them', () => { + const result = applyExactReplacements('x.x.x', [{ oldText: 'x', newText: '$y', replaceAll: true }]) + expect(result).toEqual({ ok: true, content: '$y.$y.$y', replaced: 3 }) + }) + + test('a missing or ambiguous match aborts and names the replacement', () => { + const missing = applyExactReplacements('abc', [{ oldText: 'zzz', newText: '' }]) + expect(missing.ok).toBe(false) + if (!missing.ok) expect(missing.reason).toBe('not-found') + + const ambiguous = applyExactReplacements('aXa', [{ oldText: 'a', newText: 'b' }]) + expect(ambiguous.ok).toBe(false) + if (!ambiguous.ok) { + expect(ambiguous.reason).toBe('ambiguous') + expect(ambiguous.matches).toBe(2) + expect(ambiguous.replacement.oldText).toBe('a') + } + }) + + test('replacements apply in order, each against the previous result', () => { + const result = applyExactReplacements('one two', [ + { oldText: 'one', newText: 'two' }, + { oldText: 'two', newText: 'three', replaceAll: true }, + ]) + expect(result).toEqual({ ok: true, content: 'three three', replaced: 3 }) + }) + + test('countOccurrences counts non-overlapping matches and treats an empty search as none', () => { + expect(countOccurrences('aaaa', 'aa')).toBe(2) + expect(countOccurrences('abc', '')).toBe(0) + }) +}) diff --git a/src/__tests__/architecture/branch-scope-repositories.test.ts b/src/__tests__/architecture/branch-scope-repositories.test.ts index 6dfd21e02..3b76b5a5d 100644 --- a/src/__tests__/architecture/branch-scope-repositories.test.ts +++ b/src/__tests__/architecture/branch-scope-repositories.test.ts @@ -62,7 +62,12 @@ const RAW_SQL_EXEMPT_PREFIXES = [ 'server/db/', ] -const BRANCHED_TABLE_SQL = /\b(from|into|update|join)\s+(data_rows|data_tables|site)\b/i +// A SQL clause naming a branched table. The table must be followed by SQL — +// a clause keyword, an alias-free terminator, or the end of the line — so an +// English sentence like "uses a module from site plugin X" in a log message +// is not mistaken for a query. +const BRANCHED_TABLE_SQL = + /\b(from|into|update|join)\s+(data_rows|data_tables|site)\s*(?:[`;),(]|$|\b(?:where|set|values|on|using|join|left|inner|outer|cross|order|group|having|limit|offset|returning|select|as)\b)/im function read(path: string): string { return readFileSync(join(ROOT, path), 'utf8') diff --git a/src/__tests__/architecture/bundle-size-budgets.test.ts b/src/__tests__/architecture/bundle-size-budgets.test.ts index 8dae203c3..68c047783 100644 --- a/src/__tests__/architecture/bundle-size-budgets.test.ts +++ b/src/__tests__/architecture/bundle-size-budgets.test.ts @@ -126,9 +126,12 @@ const BUDGETS: ChunkBudget[] = [ // Raised from 30 KB when site branches landed: the publish gate (disabled // with the inline reason on a branch), the version-history entry, and the // per-branch persistence hook are part of the shell by design (~+1 KB raw). - maxBytes: 32_000, + // Raised again for site plugins: the `plugins.edit` gate, the Plugin IDE + // route and workspace entry (~+0.8 KB raw). The editor body still lands in + // its own chunk — the shell carries no DnD, canvas, or module code. + maxBytes: 33_500, rationale: - 'site route shell (current ~31 KB raw / ~10 KB gzipped). Must not ' + + 'site route shell (current ~32 KB raw / ~10 KB gzipped). Must not ' + 'pull the visual editor body, DnD, canvas, first-party modules, or ' + 'PropertiesPanel back into the active route chunk.', }, @@ -227,14 +230,18 @@ function findChunk(prefix: string): { path: string; size: number } | null { (f) => f.startsWith(prefix) && f.endsWith('.js'), ) if (matches.length === 0) return null - if (matches.length > 1) { - throw new Error( - `[bundle-size-budgets] Found ${matches.length} files matching prefix ` + - `"${prefix}" in dist/assets/: ${matches.join(', ')}. Expected exactly one.`, - ) - } + // A module reachable through BOTH a dynamic and a static import (e.g. + // CodeMirrorEditor: lazy from CodeEditorPanel, static from its collab + // sibling lazy module) makes rolldown emit a tiny re-export facade chunk + // alongside the real one. Budget the SUM of all matches — that stays + // facade-tolerant while still catching genuine duplication (two full + // copies would blow the budget immediately). + const size = matches.reduce( + (total, match) => total + statSync(join(DIST_ASSETS, match)).size, + 0, + ) const path = join(DIST_ASSETS, matches[0]!) - return { path, size: statSync(path).size } + return { path, size } } function findChunks(prefix: string): string[] { @@ -319,9 +326,19 @@ describe('Bundle size budgets', () => { const iconChunkPatterns = vendoredIconNames().map( (iconName) => new RegExp(`^${regexEscape(iconName)}-[A-Za-z0-9_-]+\\.js$`), ) - const individualIconChunks = readdirSync(DIST_ASSETS).filter((asset) => - iconChunkPatterns.some((pattern) => pattern.test(asset)), - ) + const individualIconChunks = readdirSync(DIST_ASSETS).filter((asset) => { + if (!iconChunkPatterns.some((pattern) => pattern.test(asset))) return false + // Name-collision guard: rolldown names shared feature chunks after one + // of their modules, and a feature module can legitimately share a name + // with an icon (e.g. an "upload" helpers chunk vs the upload icon). A + // chunk that IMPORTS the grouped icon chunk is a consumer, not an + // escaped icon — only an actual icon module body (svg markup, no + // grouped-chunk import) counts as a violation. + const source = readFileSync(join(DIST_ASSETS, asset), 'utf8') + const importsGroupedIcons = source.includes('./pixel-art-icons-') + const looksLikeIconBody = source.includes('viewBox') + return !importsGroupedIcons && looksLikeIconBody + }) expect(individualIconChunks).toEqual([]) }) diff --git a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts index 91c88f2d8..311c691fd 100644 --- a/src/__tests__/architecture/cms-handlers-capability-gated.test.ts +++ b/src/__tests__/architecture/cms-handlers-capability-gated.test.ts @@ -50,6 +50,11 @@ const ALLOWLIST: ReadonlyMap = new Map([ // Shared utilities — body parsers, audit context helpers, schema // exports. No request handlers live here. ['shared.ts', 'Shared request helpers; no handlers.'], + // Site-plugin service layer — list projection + activation engine shared + // by the HTTP routes (sitePlugins/index.ts, which gates every route) and + // the AI plugin tools (capability-gated at tool selection + execution). + // No request handlers live here. + ['sitePlugins/service.ts', 'Transport-independent service layer; sitePlugins/index.ts and the AI tool gates own auth.'], ['session.ts', 'Session lookup helper; called from auth.ts which gates.'], // Media upload helpers — `acceptUploadedMedia`, `readUploadForm`, // file-magic sniffing. Always called by an already-gated parent diff --git a/src/__tests__/architecture/codemirror-lazy-only.test.ts b/src/__tests__/architecture/codemirror-lazy-only.test.ts index 0cf515aad..27be8e5cb 100644 --- a/src/__tests__/architecture/codemirror-lazy-only.test.ts +++ b/src/__tests__/architecture/codemirror-lazy-only.test.ts @@ -73,9 +73,21 @@ function collectProdFiles(): string[] { // CodeMirror lazy-load enforcement // --------------------------------------------------------------------------- -// The ONE file allowed to statically import the CodeMirror packages. -// Path is relative to SRC_ROOT (i.e. relative to `src/`). -const ALLOWED_CONSUMER = 'admin/pages/site/code-editor/CodeMirrorEditor.tsx' +// The files allowed to statically import the CodeMirror packages — the base +// lazy module and its collab sibling (both only reachable through +// React.lazy() boundaries; the sibling adds the y-codemirror binding for +// the Plugin IDE's co-edited buffers). +// Paths are relative to SRC_ROOT (i.e. relative to `src/`). +const ALLOWED_CONSUMERS = new Set([ + 'admin/pages/site/code-editor/CodeMirrorEditor.tsx', + 'admin/pages/site/code-editor/CollabCodeMirrorEditor.tsx', + // Read-only agent code/diff blocks — lazy-loaded by ToolCallRow, same + // chunk graph as the code editor. + 'admin/pages/site/code-editor/AgentCodeView.tsx', + // Theme + language stacks shared by the three viewers above (non-component + // module so the component files keep react-refresh eligibility). + 'admin/pages/site/code-editor/codeMirrorShared.ts', +]) // Matches: import ... from 'codemirror' / '@codemirror/...' / '@lezer/...' // require('codemirror') / require('@codemirror/...') / require('@lezer/...') @@ -98,13 +110,13 @@ const CODEMIRROR_IMPORT_PATTERNS: { family: string; pattern: RegExp }[] = [ ] describe('CodeMirror lazy-load enforcement', () => { - it(`only ${ALLOWED_CONSUMER} may statically import codemirror / @codemirror / @lezer`, () => { + it('only the code-editor lazy modules may statically import codemirror / @codemirror / @lezer', () => { const allFiles = collectProdFiles() const violations: { file: string; family: string }[] = [] for (const file of allFiles) { const rel = relative(SRC_ROOT, file) - if (rel === ALLOWED_CONSUMER) continue + if (ALLOWED_CONSUMERS.has(rel)) continue let source: string try { @@ -125,8 +137,8 @@ describe('CodeMirror lazy-load enforcement', () => { (v) => ` src/${v.file} → imports ${v.family}` ) throw new Error( - `[codemirror-lazy-only] CodeMirror must stay behind the React.lazy()\n` + - `boundary in CodeEditorPanel.tsx. Only src/${ALLOWED_CONSUMER} is\n` + + `[codemirror-lazy-only] CodeMirror must stay behind a React.lazy()\n` + + `boundary. Only ${[...ALLOWED_CONSUMERS].map((f) => `src/${f}`).join(' and ')} are\n` + `permitted to statically import CodeMirror packages. A static import\n` + `elsewhere pulls the ~605 kB CodeMirror bundle into the eager admin\n` + `chunk and undoes the code-split.\n\n` + @@ -139,11 +151,12 @@ describe('CodeMirror lazy-load enforcement', () => { expect(violations).toHaveLength(0) }) - it('the allowed consumer file actually exists at the documented path', () => { - // Sanity check — if CodeMirrorEditor.tsx is renamed or moved without - // updating ALLOWED_CONSUMER above, the gate would silently start - // failing for the lazy module itself. Detect that case directly. - const allowed = join(SRC_ROOT, ALLOWED_CONSUMER) - expect(existsSync(allowed)).toBe(true) + it('the allowed consumer files actually exist at the documented paths', () => { + // Sanity check — if a lazy module is renamed or moved without updating + // ALLOWED_CONSUMERS above, the gate would silently start failing for + // the lazy module itself. Detect that case directly. + for (const consumer of ALLOWED_CONSUMERS) { + expect(existsSync(join(SRC_ROOT, consumer))).toBe(true) + } }) }) diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 8c97aefda..1731c3b15 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -37,6 +37,8 @@ const BARRELLED_MODULES = [ 'framework-schema', 'fonts', 'collab', + 'plugin-build', + 'site-plugins', ] // Scan production + test sources in both the app and the server. diff --git a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts index 796d0c799..b183d8514 100644 --- a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts +++ b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts @@ -106,8 +106,11 @@ describe('plugin sandbox invariants', () => { expect(scanCount).toBeGreaterThanOrEqual(2) }) - it('the SDK build pipeline applies the same sandbox scan at build time', async () => { - const source = await read('src/core/plugin-sdk/cli/build.ts') + it('the shared build core applies the same sandbox scan at build time', async () => { + // The bundling pipeline lives in @core/plugin-build (shared by the CLI + // and the server-side site plugin build) — both surfaces ride the same + // scan + IIFE contract by construction. + const source = await read('src/core/plugin-build/bundle.ts') expect(source).toContain('assertSandboxSafe') // Sandboxed bundles must be emitted as IIFE (the format QuickJS can // eval). The build pipeline used to ship ESM with `export function …` diff --git a/src/__tests__/architecture/site-plugin-file-isolation.test.ts b/src/__tests__/architecture/site-plugin-file-isolation.test.ts new file mode 100644 index 000000000..3fbc120fa --- /dev/null +++ b/src/__tests__/architecture/site-plugin-file-isolation.test.ts @@ -0,0 +1,94 @@ +/** + * `SiteFileType: 'plugin'` isolation gate. + * + * Site plugin source files live in the site draft but must NEVER reach any + * visitor/module surface: published script bundles, user stylesheets, + * `props._siteScripts`, module-readable file lists — or the site editor's + * explorer (they are edited exclusively in the Plugin IDE). The pipelines + * select by EXACT type (`type === 'script'` / `type === 'style'`), so + * 'plugin' files are excluded by NOT matching; this gate pins that. + */ +import { describe, expect, test } from 'bun:test' +import { collectAppliedStyles, collectRuntimeScripts, DEFAULT_SITE_RUNTIME } from '@core/site-runtime' +import { reconcileSiteExplorerOrganization } from '@core/page-tree' +import type { SiteFile } from '@core/files/schemas' + +const file = (id: string, path: string, type: SiteFile['type'], content = 'export {}'): SiteFile => ({ + id, + path, + type, + content, + createdAt: 1, + updatedAt: 1, +}) + +const page = { id: 'page-1' } + +describe('site plugin file isolation', () => { + test("'plugin' files never become runtime scripts", () => { + const files = [ + file('f1', 'plugins/newsletter/server/index.ts', 'plugin'), + file('f2', 'src/scripts/fx.ts', 'script'), + ] + const collected = collectRuntimeScripts({ + files, + runtime: DEFAULT_SITE_RUNTIME, + page, + target: 'published', + }) + expect(collected.map((entry) => entry.file.id)).toEqual(['f2']) + }) + + test("'plugin' files never become user stylesheets", () => { + const files = [ + file('f1', 'plugins/newsletter/frontend/styles.css', 'plugin', 'body { color: red }'), + file('f2', 'src/styles/site.css', 'style', 'body { margin: 0 }'), + ] + const collected = collectAppliedStyles({ + files, + runtime: DEFAULT_SITE_RUNTIME, + page, + }) + expect(collected.map((entry) => entry.file.id)).toEqual(['f2']) + }) + + test("site explorer reconciliation drops 'plugin' files from the scripts section", () => { + const files = [ + file('f1', 'plugins/demo/server/index.ts', 'plugin'), + file('f2', 'src/scripts/fx.ts', 'script'), + ] + // Reconciliation keeps rowOrder entries only for rows the section still + // derives from `files` — a 'plugin' file must never be a scripts row. + const organization = reconcileSiteExplorerOrganization( + { + pages: { expandedFolders: [], emptyFolders: [], rowOrder: [] }, + styles: { expandedFolders: [], emptyFolders: [], rowOrder: [] }, + scripts: { + expandedFolders: [], + emptyFolders: [], + rowOrder: [ + { kind: 'item', id: 'f1', parentPath: 'plugins/demo/server', order: 0 }, + { kind: 'item', id: 'f2', parentPath: 'src/scripts', order: 1 }, + ], + }, + templates: { folders: [], items: [] }, + components: { folders: [], items: [] }, + }, + { pages: [], visualComponents: [], files }, + ) + const scriptRowIds = organization.scripts.rowOrder.map((row) => row.id) + expect(scriptRowIds).toContain('f2') + expect(scriptRowIds).not.toContain('f1') + }) + + test("no published-output pipeline matches 'plugin' by type", async () => { + // Static gate: the string type === 'plugin' must not appear in any + // published-output pipeline — 'plugin' files are excluded by NOT + // matching, never included by matching. + const glob = new Bun.Glob('{server/publish,src/core/publisher,src/core/site-runtime}/**/*.ts') + for await (const path of glob.scan('.')) { + const text = await Bun.file(path).text() + expect(text.includes("type === 'plugin'"), `${path} matches 'plugin' file type`).toBe(false) + } + }) +}) diff --git a/src/__tests__/architecture/site-plugin-invariants.test.ts b/src/__tests__/architecture/site-plugin-invariants.test.ts new file mode 100644 index 000000000..2a0056b53 --- /dev/null +++ b/src/__tests__/architecture/site-plugin-invariants.test.ts @@ -0,0 +1,78 @@ +/** + * Site plugin invariants — the contracts no unit test above owns + * (design: docs/plans/2026-07-02-site-local-integrations-design.md; + * feature: docs/features/site-plugins.md). + * + * The load-bearing simplification: a site plugin IS an installed plugin at + * runtime. Provenance ('site-local') exists for display + lifecycle + * routing only — the worker, VM, host bridges, and publish machinery must + * never branch on it. + */ +import { describe, expect, test } from 'bun:test' + +describe('site plugin invariants', () => { + test("the runtime has zero 'site-local' branches", async () => { + // The string 'site-local' may appear ONLY in: the repository (row + // plumbing), the site plugin engine/handlers, the install seam, the + // SDK's provenance type, and UI presentation. + const allowed = [ + /server\/repositories\/plugins\.ts$/, + /server\/plugins\/sitePlugins\//, + /server\/handlers\/cms\/sitePlugins\//, + /server\/handlers\/cms\/plugins\/install\.ts$/, + /server\/db\/migrations-(pg|sqlite)\.ts$/, // the source-column migration comment/default + /src\/core\/plugin-sdk\/types\/installedPlugin\.ts$/, + /src\/core\/site-plugins\//, + /src\/admin\/pages\/plugins\//, + ] + const glob = new Bun.Glob('{server,src}/**/*.{ts,tsx}') + const offenders: string[] = [] + for await (const path of glob.scan('.')) { + if (path.includes('__tests__')) continue + const text = await Bun.file(path).text() + if (!text.includes("'site-local'")) continue + if (allowed.some((pattern) => pattern.test(path))) continue + offenders.push(path) + } + expect(offenders).toEqual([]) + }) + + test('worker / VM / host-bridge plugin machinery never mentions site plugins', async () => { + const glob = new Bun.Glob('server/plugins/{quickjs,host,protocol}/**/*.ts') + for await (const path of glob.scan('.')) { + const text = await Bun.file(path).text() + expect(text.includes('sitePlugin'), `${path} references sitePlugin`).toBe(false) + expect(text.includes("'site-local'"), `${path} branches on 'site-local'`).toBe(false) + } + }) + + test('the zip-install boundary rejects the reserved namespace (source pin)', async () => { + const text = await Bun.file('server/plugins/package.ts').text() + expect(text).toContain('isReservedSitePluginId') + }) + + test('site plugin builds always pass an import-containment policy', async () => { + // The server build orchestrator must never call buildPluginPackage + // without `resolve:` — dropping it would let draft imports read host + // files. Pin the call shape. + const text = await Bun.file('server/plugins/sitePlugins/build.ts').text() + expect(text).toContain('buildPluginPackage') + expect(text).toContain('workspaceRoot: workspace.rootDir') + expect(text).toContain("'@instatic/plugin-sdk': SDK_ENTRY") + }) + + test('activation authority stays with plugins.install + conditional step-up', async () => { + const routes = await Bun.file('server/handlers/cms/sitePlugins/index.ts').text() + // Activate + rollback gate on plugins.install… + expect(routes.match(/requireCapability\(req, db, 'plugins\.install'\)/g)?.length).toBeGreaterThanOrEqual(2) + // …and step-up runs behind the grant-diff (the engine reports + // grants-changed; only the HTTP wrapper may step up and re-run). + expect(routes).toContain("result.status === 'grants-changed'") + expect(routes).toContain('requireStepUp') + const service = await Bun.file('server/handlers/cms/sitePlugins/service.ts').text() + expect(service).toContain('grantsChanged') + // The engine itself must never import interactive auth — consent stays + // with the HTTP wrapper. + expect(service.includes('requireStepUp')).toBe(false) + }) +}) diff --git a/src/__tests__/collab/filesGranular.test.ts b/src/__tests__/collab/filesGranular.test.ts new file mode 100644 index 000000000..34d2245ef --- /dev/null +++ b/src/__tests__/collab/filesGranular.test.ts @@ -0,0 +1,181 @@ +/** + * Granular shell files — the shell's `files` key is a per-file Y.Map with + * `content` as Y.Text, so code files co-edit with the same guarantees as + * canvas text: different files never collide, one file's content merges + * character-level, and a delete of one file can't clobber a neighbour's + * concurrent edit. This is what the site editor's code panel and the + * Plugin IDE both ride. + */ +import { describe, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { create } from 'mutative' +import '@modules/base' +import { + applySitePatchesToDocs, + createCollabDocSet, + projectSiteDoc, + seedSiteDoc, + shellMap, + siteFileContentText, + MAIN_SITE_DOC_ID, +} from '@core/collab' +import { MAIN_BRANCH_ID } from '@core/branches' +import type { SiteDocument } from '@core/page-tree' +import type { SiteFile } from '@core/files/schemas' +import { makeSite } from '../fixtures' + +const file = (id: string, path: string, content: string, createdAt = 1): SiteFile => ({ + id, + path, + type: 'script', + content, + createdAt, + updatedAt: createdAt, +}) + +function siteWithFiles(): SiteDocument { + return makeSite({ + files: [ + file('f1', 'src/scripts/a.ts', 'const a = 1\n', 1), + file('f2', 'src/scripts/b.ts', 'const b = 2\n', 2), + ], + }) +} + +function seededPair(site: SiteDocument): [Y.Doc, Y.Doc] { + const a = new Y.Doc() + seedSiteDoc(a, site) + const b = new Y.Doc() + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) + return [a, b] +} + +function syncDocs(a: Y.Doc, b: Y.Doc): void { + Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b))) + Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a))) +} + +/** Run one store-style mutation through the patch translator against `doc`. */ +function mutateThroughPatches( + doc: Y.Doc, + site: SiteDocument, + recipe: (draft: SiteDocument) => void, +): SiteDocument { + const [next, patches] = create(site, recipe, { enablePatches: true }) + const docs = createCollabDocSet() + docs.set(MAIN_SITE_DOC_ID, doc) + applySitePatchesToDocs(patches, site, next, docs, 'test-local', MAIN_BRANCH_ID) + return next +} + +function projectedFiles(doc: Y.Doc): SiteFile[] { + return projectSiteDoc(doc).shell['files'] as SiteFile[] +} + +describe('granular shell files', () => { + it('seed + project round-trips the files array', () => { + const site = siteWithFiles() + const [a] = seededPair(site) + expect(projectedFiles(a)).toEqual(site.files) + }) + + it('content edits translate to Y.Text splices that merge character-level', () => { + const site = siteWithFiles() + const [a, b] = seededPair(site) + + mutateThroughPatches(a, site, (draft) => { + draft.files[0]!.content = '// top\nconst a = 1\n' + }) + mutateThroughPatches(b, site, (draft) => { + draft.files[0]!.content = 'const a = 1\n// bottom\n' + }) + syncDocs(a, b) + + const merged = projectedFiles(a).find((f) => f.id === 'f1')!.content + expect(merged).toContain('// top') + expect(merged).toContain('// bottom') + expect(merged).toContain('const a = 1') + expect(projectedFiles(b).find((f) => f.id === 'f1')!.content).toBe(merged) + }) + + it('deleting one file never clobbers a concurrent edit to another', () => { + const site = siteWithFiles() + const [a, b] = seededPair(site) + + // Peer A deletes f1 (shifts f2 to index 0 — Mutative emits replace ops + // for the shifted entry); peer B concurrently edits f2's content. + mutateThroughPatches(a, site, (draft) => { + draft.files.splice(0, 1) + }) + mutateThroughPatches(b, site, (draft) => { + draft.files[1]!.content = 'const b = 2\n// edited\n' + }) + syncDocs(a, b) + + for (const doc of [a, b]) { + const files = projectedFiles(doc) + expect(files.map((f) => f.id)).toEqual(['f2']) + expect(files[0]!.content).toContain('// edited') + } + }) + + it('adding files on both peers keeps both', () => { + const site = siteWithFiles() + const [a, b] = seededPair(site) + + mutateThroughPatches(a, site, (draft) => { + draft.files.push(file('f3', 'src/scripts/c.ts', 'const c = 3\n', 3)) + }) + mutateThroughPatches(b, site, (draft) => { + draft.files.push(file('f4', 'src/scripts/d.ts', 'const d = 4\n', 4)) + }) + syncDocs(a, b) + + for (const doc of [a, b]) { + expect(projectedFiles(doc).map((f) => f.id)).toEqual(['f1', 'f2', 'f3', 'f4']) + } + }) + + it('renames merge with concurrent content edits to the same file', () => { + const site = siteWithFiles() + const [a, b] = seededPair(site) + + mutateThroughPatches(a, site, (draft) => { + draft.files[0]!.path = 'src/scripts/renamed.ts' + }) + mutateThroughPatches(b, site, (draft) => { + draft.files[0]!.content = 'const a = 1\n// note\n' + }) + syncDocs(a, b) + + const merged = projectedFiles(a).find((f) => f.id === 'f1')! + expect(merged.path).toBe('src/scripts/renamed.ts') + expect(merged.content).toContain('// note') + }) + + it('siteFileContentText exposes the live Y.Text for editor bindings', () => { + const site = siteWithFiles() + const [a] = seededPair(site) + const text = siteFileContentText(shellMap(a), 'f1') + expect(text).toBeInstanceOf(Y.Text) + expect(text!.toString()).toBe('const a = 1\n') + expect(siteFileContentText(shellMap(a), 'nope')).toBeNull() + }) + + it('legacy LWW-array layout projects as-is and upgrades on first write', () => { + const site = siteWithFiles() + const doc = new Y.Doc() + seedSiteDoc(doc, { ...site, files: [] }) + // Simulate a pre-granular doc: plain array value under 'files'. + doc.transact(() => { + shellMap(doc).set('files', site.files) + }) + expect(projectedFiles(doc)).toEqual(site.files) + + mutateThroughPatches(doc, site, (draft) => { + draft.files[0]!.content = 'upgraded\n' + }) + expect(shellMap(doc).get('files')).toBeInstanceOf(Y.Map) + expect(projectedFiles(doc).find((f) => f.id === 'f1')!.content).toBe('upgraded\n') + }) +}) diff --git a/src/__tests__/helpers/capabilityHarness.ts b/src/__tests__/helpers/capabilityHarness.ts index cff6181e1..a7d831bde 100644 --- a/src/__tests__/helpers/capabilityHarness.ts +++ b/src/__tests__/helpers/capabilityHarness.ts @@ -107,7 +107,7 @@ export async function createCapabilityTestHarness( const ai = async (path: string, requestOptions: HarnessRequestInit = {}) => { const req = buildRequest(path, requestOptions) - const response = await tryHandleAi(req, db, new URL(req.url)) + const response = await tryHandleAi(req, db, new URL(req.url), { uploadsDir: options.uploadsDir }) return response ?? new Response(JSON.stringify({ error: 'Not found' }), { status: 404 }) } diff --git a/src/__tests__/panels/domPanel.test.tsx b/src/__tests__/panels/domPanel.test.tsx index b15d63310..d12b65983 100644 --- a/src/__tests__/panels/domPanel.test.tsx +++ b/src/__tests__/panels/domPanel.test.tsx @@ -527,28 +527,29 @@ describe('DomPanel — open container group highlight', () => { fireEvent.click(rootItem) const containerWrapper = document.querySelector('[data-node-id="container-1"]') - expect(containerWrapper?.getAttribute('data-open-container-group')).toBeNull() + expect(containerWrapper?.getAttribute('data-tree-group-open')).toBeNull() const containerItem = screen.getByRole('treeitem', { name: /container/i }) fireEvent.click(containerItem) - expect(containerWrapper?.getAttribute('data-open-container-group')).toBe('true') + expect(containerWrapper?.getAttribute('data-tree-group-open')).toBe('true') }) - it('styles open container groups with a rounded background on the wrapper', () => { + it('paints open container groups through the shared TreeGroup primitive', () => { const source = readFileSync(TREE_NODE_SOURCE_PATH, 'utf8') const rowCss = readFileSync(TREE_ROW_CSS_PATH, 'utf8') - const css = readFileSync(TREE_NODE_CSS_PATH, 'utf8') - expect(source).toContain('styles.openContainerGroup') - expect(css).toContain('.openContainerGroup') + // The wrapper IS the primitive: no panel-local group styling. + expect(source).toContain(' { @@ -563,13 +564,13 @@ describe('DomPanel — open container group highlight', () => { const containerItems = screen.getAllByRole('treeitem', { name: /container/i }) fireEvent.click(containerItems[0]) - expect(firstWrapper?.getAttribute('data-open-container-group')).toBe('true') - expect(secondWrapper?.getAttribute('data-open-container-group')).toBeNull() + expect(firstWrapper?.getAttribute('data-tree-group-open')).toBe('true') + expect(secondWrapper?.getAttribute('data-tree-group-open')).toBeNull() fireEvent.click(containerItems[1]) - expect(firstWrapper?.getAttribute('data-open-container-group')).toBeNull() - expect(secondWrapper?.getAttribute('data-open-container-group')).toBe('true') + expect(firstWrapper?.getAttribute('data-tree-group-open')).toBeNull() + expect(secondWrapper?.getAttribute('data-tree-group-open')).toBe('true') }) }) diff --git a/src/__tests__/plugins/pluginBuildContainment.test.ts b/src/__tests__/plugins/pluginBuildContainment.test.ts new file mode 100644 index 000000000..be1d01557 --- /dev/null +++ b/src/__tests__/plugins/pluginBuildContainment.test.ts @@ -0,0 +1,207 @@ +/** + * Import containment for workspace plugin builds — the resolve plugin must + * fail closed: every import originating inside the workspace resolves inside + * it; bare specifiers are rejected unless mapped; absolute paths and + * upward-relative escapes throw. Without this, draft code could embed host + * files (env files, DB files) into a bundle and exfiltrate them through the + * plugin's own public routes. + */ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { assertNoBuildTimeMacros, buildPluginPackage } from '@core/plugin-build' +import { parsePluginManifest } from '@core/plugins/manifest' + +async function workspace(files: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'containment-')) + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path) + await mkdir(dirname(absolute), { recursive: true }) + await writeFile(absolute, content, 'utf8') + } + return root +} + +const MANIFEST = parsePluginManifest({ + id: 'site.demo', + name: 'Demo', + version: '1.0.1+aaaa1111', + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + entrypoints: { server: 'server/index.js' }, +}) + +const SDK_ENTRY = resolve(import.meta.dir, '../../core/plugin-sdk/index.ts') + +describe('plugin build import containment', () => { + test('relative import inside the workspace bundles fine', async () => { + const root = await workspace({ + 'server/index.ts': "import { x } from '../shared/util'\nexport function activate() { return x }", + 'shared/util.ts': 'export const x = 1', + }) + try { + const out = join(root, '.dist') + const result = await buildPluginPackage({ + sourceDir: root, + outputDir: out, + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }) + expect(result.files).toContain('server/index.js') + expect(existsSync(join(out, 'server/index.js'))).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('upward escape outside the workspace fails the build', async () => { + const root = await workspace({ + 'server/index.ts': "import secret from '../../outside'\nexport function activate() { return secret }", + }) + await writeFile(join(root, '..', 'outside.ts'), 'export default 42', 'utf8') + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/outside the plugin workspace/) + } finally { + await rm(join(root, '..', 'outside.ts'), { force: true }) + await rm(root, { recursive: true, force: true }) + } + }) + + test('absolute-path import-attribute payload fails the build', async () => { + const root = await workspace({ + 'server/index.ts': "import x from '/etc/hosts' with { type: 'text' }\nexport function activate() { return x }", + }) + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/outside the plugin workspace/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('a build-time macro import fails the build before the macro runs', async () => { + // Bun evaluates `with { type: 'macro' }` imports in the host process at + // bundle time. The macro module resolves INSIDE the workspace, so path + // containment cannot catch it — the source scan must refuse it first. + const marker = join(tmpdir(), `containment-macro-${process.pid}-${Date.now()}.txt`) + const root = await workspace({ + 'server/m.ts': + `import { writeFileSync } from 'node:fs'\n` + + `export function pwn() { writeFileSync(${JSON.stringify(marker)}, 'ran'); return 1 }\n`, + 'server/index.ts': + "import { pwn } from './m.ts' with { type: 'macro' }\nexport function activate() { return pwn() }\n", + }) + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/build-time macros/) + expect(existsSync(marker)).toBe(false) + } finally { + await rm(marker, { force: true }) + await rm(root, { recursive: true, force: true }) + } + }) + + test('inert import attributes inside the workspace still bundle', async () => { + const root = await workspace({ + 'server/index.ts': + "import data from './data.json' with { type: 'json' }\nexport function activate() { return data.answer }", + 'server/data.json': '{ "answer": 42 }', + }) + try { + const result = await buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }) + expect(result.files).toContain('server/index.js') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('the macro scan is fail-closed against every spelling of the clause', () => { + const rejected = [ + "import { x } from './m.ts' with { type: 'macro' }", + 'import { x } from "./m.ts" with {type:"macro"}', + "import { x } from './m.ts' assert { type: 'macro' }", + "import { x } from './m.ts' with /* hidden */ { type: 'macro' }", + "import { x } from './m.ts' with { 'type': 'macro' }", + "import { x } from './m.ts' with { type: 'macro' /* } */ }", + "import { x } from './m.ts' with { type: 'json', extra: 1 }", + "const m = await import('./m.ts', { with: { type: 'macro' } })", + "import { x } from './m.ts' with { \\u0074ype: 'macro' }", + ] + for (const source of rejected) { + expect(() => assertNoBuildTimeMacros(source, 'index.ts')).toThrow(/not allowed/) + } + const accepted = [ + "import data from './d.json' with { type: 'json' }", + 'import text from "./t.txt" with { "type": "text" }', + "import cfg from './c.toml' assert { type: 'toml' }", + "import { pwn } from './m.ts'\nexport const withBraces = { with: 1 }", + ] + for (const source of accepted) { + expect(() => assertNoBuildTimeMacros(source, 'index.ts')).not.toThrow() + } + }) + + test('unlisted bare specifier fails; mapped one resolves to the host SDK', async () => { + const rejected = await workspace({ + 'server/index.ts': "import { html } from '@instatic/plugin-sdk'\nexport function activate() { return html }", + }) + try { + await expect( + buildPluginPackage({ + sourceDir: rejected, + outputDir: join(rejected, '.dist'), + manifest: MANIFEST, + resolve: { workspaceRoot: rejected, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/not an allowed dependency/) + } finally { + await rm(rejected, { recursive: true, force: true }) + } + + const mapped = await workspace({ + 'server/index.ts': "import { html } from '@instatic/plugin-sdk'\nexport function activate() { return String(html) }", + }) + try { + const result = await buildPluginPackage({ + sourceDir: mapped, + outputDir: join(mapped, '.dist'), + manifest: MANIFEST, + resolve: { + workspaceRoot: mapped, + bareSpecifiers: { '@instatic/plugin-sdk': SDK_ENTRY }, + }, + }) + expect(result.files).toContain('server/index.js') + } finally { + await rm(mapped, { recursive: true, force: true }) + } + }) +}) diff --git a/src/__tests__/plugins/pluginBuildDiagnostics.test.ts b/src/__tests__/plugins/pluginBuildDiagnostics.test.ts new file mode 100644 index 000000000..327a67f16 --- /dev/null +++ b/src/__tests__/plugins/pluginBuildDiagnostics.test.ts @@ -0,0 +1,61 @@ +/** + * Build diagnostics name the author's file with a 1-based line and column, + * and the modules pack's generated facade never appears as the location — + * the position inside the author's module file does. + */ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { buildPluginPackage, formatBuildLog } from '@core/plugin-build' +import { parsePluginManifest } from '@core/plugins/manifest' + +async function workspace(files: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'diagnostics-')) + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path) + await mkdir(dirname(absolute), { recursive: true }) + await writeFile(absolute, content, 'utf8') + } + return root +} + +describe('plugin build diagnostics', () => { + test('formatBuildLog prefixes file:line:col with a 1-based column', () => { + expect( + formatBuildLog({ + message: 'Expected identifier but found "{"', + position: { file: 'plugins/x/modules/foo.ts', line: 3, column: 17 }, + }), + ).toBe('plugins/x/modules/foo.ts:3:18: Expected identifier but found "{"') + expect(formatBuildLog({ message: 'no position', position: null })).toBe('no position') + }) + + test('a syntax error in a module file is reported at that file, not the facade', async () => { + const root = await workspace({ + 'modules/banner.ts': 'export default {\n id: "x",\n broken: {{{\n}\n', + }) + const manifest = parsePluginManifest({ + id: 'site.demo', + name: 'Demo', + version: '1.0.1+aaaa1111', + apiVersion: 1, + permissions: ['modules.register'], + resources: [], + adminPages: [], + entrypoints: { modules: 'modules/index.js' }, + }) + try { + await expect( + buildPluginPackage({ + sourceDir: root, + outputDir: join(root, '.dist'), + manifest, + resolve: { workspaceRoot: root, bareSpecifiers: {} }, + }), + ).rejects.toThrow(/the modules pack \(modules\/\*\)[\s\S]*modules\/banner\.ts:3:\d+: /) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/__tests__/plugins/pluginsAdmin.test.tsx b/src/__tests__/plugins/pluginsAdmin.test.tsx index e38ad9933..95dff379a 100644 --- a/src/__tests__/plugins/pluginsAdmin.test.tsx +++ b/src/__tests__/plugins/pluginsAdmin.test.tsx @@ -95,6 +95,9 @@ function ambientFetchFallback(url: string): Response | undefined { if (url.endsWith('/admin/api/cms/site')) { return json({ site: null }, 404) } + if (url.endsWith('/admin/api/cms/site-plugins')) { + return json({ sitePlugins: [] }) + } if (url.endsWith('/admin/api/cms/site/publish-status')) { return json({ ok: false }, 404) } @@ -234,7 +237,7 @@ describe('PluginsPage', () => { , ) - expect(await screen.findByText('No plugins installed yet.')).toBeDefined() + expect(await screen.findByText('No plugins yet — upload one, or create a site plugin in the IDE.')).toBeDefined() const input = screen.getByLabelText('Plugin file') fireEvent.change(input, { @@ -293,7 +296,7 @@ describe('PluginsPage', () => { , ) - expect(await screen.findByText('No plugins installed yet.')).toBeDefined() + expect(await screen.findByText('No plugins yet — upload one, or create a site plugin in the IDE.')).toBeDefined() fireEvent.change(screen.getByLabelText('Plugin file'), { target: { diff --git a/src/__tests__/server/cmsMigrations.test.ts b/src/__tests__/server/cmsMigrations.test.ts index bc8b7fe3f..cf796d82a 100644 --- a/src/__tests__/server/cmsMigrations.test.ts +++ b/src/__tests__/server/cmsMigrations.test.ts @@ -62,13 +62,25 @@ describe('CMS migrations', () => { it('seeds the expected system roles in both dialects', () => { const pgSql = pgMigrations.map((m) => m.sql).join('\n') const sqliteSql = sqliteMigrations.map((m) => m.sql).join('\n') + // The migration seed is the INITIAL snapshot only — Owner/Admin are + // force-resynced from SYSTEM_ROLES on every boot (syncSystemRoles), so a + // capability added after the seed migration deliberately never appears + // in migration SQL (committed migrations are immutable). The gate here + // is: every role slug is seeded, and every capability the seed DOES + // grant is still a real capability (no orphaned strings surviving a + // rename). + const seededCapabilities = new Set( + [...`${pgSql}\n${sqliteSql}`.matchAll(/"([a-z]+(?:\.[a-zA-Z]+)+)"/g)] + .map((match) => match[1]!) + .filter((value) => /^(dashboard|site|pages|content|media|runtime|storage|plugins|users|roles|audit|data|ai)\./.test(value)), + ) + const known = new Set(SYSTEM_ROLES.flatMap((role) => role.capabilities)) + for (const seeded of seededCapabilities) { + expect(known.has(seeded), `migration seeds unknown capability "${seeded}"`).toBe(true) + } for (const role of SYSTEM_ROLES) { expect(pgSql).toContain(`'${role.slug}'`) expect(sqliteSql).toContain(`'${role.slug}'`) - for (const capability of role.capabilities) { - expect(pgSql).toContain(capability) - expect(sqliteSql).toContain(capability) - } } }) diff --git a/src/__tests__/server/keyedSerial.test.ts b/src/__tests__/server/keyedSerial.test.ts new file mode 100644 index 000000000..9aaa0a8d4 --- /dev/null +++ b/src/__tests__/server/keyedSerial.test.ts @@ -0,0 +1,70 @@ +/** + * Per-key promise serializer — same-key calls run one at a time in order, + * different keys interleave, and a rejection never blocks the queue. + */ +import { describe, expect, test } from 'bun:test' +import { createKeyedSerializer } from '../../../server/util/keyedSerial' + +function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (err: unknown) => void } { + let resolve!: (value: T) => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createKeyedSerializer', () => { + test('same-key calls never overlap and keep arrival order', async () => { + const serialize = createKeyedSerializer() + const events: string[] = [] + const first = deferred() + + const a = serialize('plugin', async () => { + events.push('a:start') + await first.promise + events.push('a:end') + return 'a' + }) + const b = serialize('plugin', async () => { + events.push('b:start') + return 'b' + }) + + await Promise.resolve() + expect(events).toEqual(['a:start']) + first.resolve() + expect(await a).toBe('a') + expect(await b).toBe('b') + expect(events).toEqual(['a:start', 'a:end', 'b:start']) + }) + + test('different keys run concurrently', async () => { + const serialize = createKeyedSerializer() + const gate = deferred() + const events: string[] = [] + + const a = serialize('one', async () => { + events.push('one') + await gate.promise + }) + const b = serialize('two', async () => { + events.push('two') + }) + await b + expect(events).toEqual(['one', 'two']) + gate.resolve() + await a + }) + + test('a rejected call surfaces to its caller and does not block the next one', async () => { + const serialize = createKeyedSerializer() + const failing = serialize('plugin', async () => { + throw new Error('boom') + }) + const following = serialize('plugin', async () => 'ok') + await expect(failing).rejects.toThrow('boom') + expect(await following).toBe('ok') + }) +}) diff --git a/src/__tests__/server/pluginPackageNamespace.test.ts b/src/__tests__/server/pluginPackageNamespace.test.ts new file mode 100644 index 000000000..da8b6abb1 --- /dev/null +++ b/src/__tests__/server/pluginPackageNamespace.test.ts @@ -0,0 +1,56 @@ +/** + * The `site.` plugin-id namespace is reserved for site plugins generated + * from the site draft. The zip-install boundary (`readPluginPackage`) must + * reject uploaded packages that claim it — otherwise a zip could hijack a + * site plugin's runtime identity, grants, settings, and secrets. The + * manifest PARSER keeps accepting `site.*` ids because generated site-plugin + * packages parse through it. + */ +import { describe, expect, it } from 'bun:test' +import { zipSync, strToU8 } from 'fflate' +import { isReservedSitePluginId, parsePluginManifest } from '@core/plugins/manifest' +import { readPluginPackage } from '../../../server/plugins/package' + +function pluginZip(files: Record): File { + const zipped = zipSync(Object.fromEntries( + Object.entries(files).map(([path, content]) => [path, strToU8(content)]), + )) + return new File([zipped], 'site-newsletter.zip', { type: 'application/zip' }) +} + +describe('site.* plugin id namespace', () => { + it('isReservedSitePluginId flags the reserved namespace', () => { + expect(isReservedSitePluginId('site.newsletter')).toBe(true) + expect(isReservedSitePluginId('site.a.b')).toBe(true) + expect(isReservedSitePluginId('acme.workflow')).toBe(false) + // 'sitemap.tools' must NOT be caught by a naive startsWith('site') + expect(isReservedSitePluginId('sitemap.tools')).toBe(false) + }) + + it('the zip boundary rejects site.* package ids', async () => { + const manifest = { + id: 'site.newsletter', + name: 'Newsletter', + version: '1.0.0', + apiVersion: 1, + permissions: [], + adminPages: [], + } + await expect(readPluginPackage(pluginZip({ + 'plugin.json': JSON.stringify(manifest), + }))).rejects.toThrow(/reserved "site\." namespace/) + }) + + it('the manifest parser still accepts site.* ids (generated packages)', () => { + const parsed = parsePluginManifest({ + id: 'site.newsletter', + name: 'Newsletter', + version: '1.0.1+abcd1234', + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + }) + expect(parsed.id).toBe('site.newsletter') + }) +}) diff --git a/src/__tests__/server/pluginSourceColumn.test.ts b/src/__tests__/server/pluginSourceColumn.test.ts new file mode 100644 index 000000000..1e0013d82 --- /dev/null +++ b/src/__tests__/server/pluginSourceColumn.test.ts @@ -0,0 +1,59 @@ +/** + * `installed_plugins.source` — provenance column added by migration 022. + * Zip/JSON installs default to 'installed'; site plugin activations pass + * { source: 'site-local' }. The value must round-trip reads and survive the + * upgrade upsert. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { parsePluginManifest } from '@core/plugins/manifest' +import { getInstalledPlugin, installPlugin } from '../../../server/repositories/plugins' +import { createTestDb, type TestDb } from '../helpers/createTestDb' + +function manifest(id: string, version = '1.0.0') { + return parsePluginManifest({ + id, + name: id, + version, + apiVersion: 1, + permissions: [], + resources: [], + adminPages: [], + }) +} + +let testDb: TestDb + +beforeAll(async () => { + testDb = await createTestDb() +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +describe('installed_plugins.source', () => { + test('defaults to installed and round-trips site-local', async () => { + const { db } = testDb + + const installed = await installPlugin(db, manifest('acme.demo'), []) + expect(installed.source).toBe('installed') + + const siteLocal = await installPlugin(db, manifest('site.demo', '1.0.1+aaaa1111'), [], { + source: 'site-local', + }) + expect(siteLocal.source).toBe('site-local') + + const read = await getInstalledPlugin(db, 'site.demo') + expect(read?.kind).toBe('ok') + if (read?.kind === 'ok') expect(read.plugin.source).toBe('site-local') + }) + + test('upgrade upsert preserves the site-local provenance', async () => { + const { db } = testDb + const upgraded = await installPlugin(db, manifest('site.demo', '1.0.2+bbbb2222'), [], { + source: 'site-local', + }) + expect(upgraded.source).toBe('site-local') + expect(upgraded.version).toBe('1.0.2+bbbb2222') + }) +}) diff --git a/src/__tests__/server/sitePluginAiTools.test.ts b/src/__tests__/server/sitePluginAiTools.test.ts new file mode 100644 index 000000000..6913840fa --- /dev/null +++ b/src/__tests__/server/sitePluginAiTools.test.ts @@ -0,0 +1,207 @@ +/** + * Plugin-scope AI tools — selection-time capability filtering and the + * server-resolved lifecycle handlers (`plugin_validate`, `plugin_activate`, + * `plugin_list_plugins`). + * + * The activation consent invariant under test: the agent may rebuild + * same-grant revisions autonomously, but any grant-set change (including + * the first activation) is refused with an instruction to confirm in the + * IDE header — the tool path can never replace the human step-up. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { CoreCapability } from '@core/capabilities' +import type { AiToolOutput } from '@core/ai' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { selectToolsForScope } from '../../../server/ai/tools' +import { pluginTools } from '../../../server/ai/tools/plugin' +import type { AiTool, ToolContext } from '../../../server/ai/runtime/types' + +let harness: CapabilityTestHarness +let uploadsDir: string +let ownerCookie: string +let ownerId: string + +beforeAll(async () => { + uploadsDir = await mkdtemp(join(tmpdir(), 'site-plugin-ai-uploads-')) + harness = await createCapabilityTestHarness({ uploadsDir }) + ownerCookie = await harness.setupOwner() + const me = await harness.cms('/admin/api/cms/me', { cookie: ownerCookie }) + ownerId = (await readJson<{ user: { id: string } }>(me)).user.id +}) + +afterAll(async () => { + await harness.cleanup() + await rm(uploadsDir, { recursive: true, force: true }) +}) + +function toolByName(name: string): AiTool { + const tool = pluginTools.find((candidate) => candidate.name === name) + if (!tool) throw new Error(`no plugin tool named ${name}`) + return tool +} + +function ctx(capabilities: CoreCapability[], snapshot: unknown = null): ToolContext { + return { + db: harness.db, + userId: ownerId, + capabilities, + scope: 'plugin', + conversationId: 'test', + snapshot, + uploadsDir, + signal: new AbortController().signal, + } +} + +const OWNER_CAPS: CoreCapability[] = [ + 'site.read', + 'plugins.read', + 'plugins.edit', + 'plugins.install', + 'ai.chat', + 'ai.tools.write', +] + +async function run(name: string, input: unknown, context: ToolContext): Promise { + const tool = toolByName(name) + return (await tool.handler!(input, context)) as AiToolOutput +} + +describe('plugin toolset selection', () => { + test('a reader sees read tools only', () => { + const offered = selectToolsForScope('plugin', ['ai.chat', 'site.read']) + const names = offered.map((tool) => tool.name).sort() + expect(names).toEqual(['plugin_docs', 'plugin_list_files', 'plugin_list_plugins', 'plugin_read_file', 'plugin_validate']) + }) + + test('write tools require ai.tools.write AND plugins.edit', () => { + const withoutEdit = selectToolsForScope('plugin', ['ai.chat', 'ai.tools.write', 'site.read']) + expect(withoutEdit.some((tool) => tool.name === 'plugin_write_file')).toBe(false) + // plugin_open_file is a pure editor-state switch — write-flag gated only. + expect(withoutEdit.some((tool) => tool.name === 'plugin_open_file')).toBe(true) + + const withEdit = selectToolsForScope('plugin', ['ai.chat', 'ai.tools.write', 'plugins.edit']) + const names = withEdit.map((tool) => tool.name) + expect(names).toContain('plugin_write_file') + expect(names).toContain('plugin_patch_file') + expect(names).toContain('plugin_rename_file') + expect(names).toContain('plugin_delete_file') + // …but not activate (plugins.install) or the site.read-gated reads. + expect(names).not.toContain('plugin_activate') + }) + + test('plugin_activate requires plugins.install', () => { + const installer = selectToolsForScope('plugin', ['ai.chat', 'ai.tools.write', 'plugins.install']) + expect(installer.some((tool) => tool.name === 'plugin_activate')).toBe(true) + }) +}) + +describe('plugin lifecycle tool handlers', () => { + test('plugin_validate validates a scaffolded plugin (explicit localId)', async () => { + const scaffold = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: ownerCookie, + json: { name: 'Agent Probe', localId: 'agent-probe', template: 'routes' }, + }) + expect(scaffold.status).toBe(201) + + const result = await run('plugin_validate', { localId: 'agent-probe' }, ctx(OWNER_CAPS)) + expect(result.ok).toBe(true) + expect(result.data).toEqual({ ok: true, diagnostics: [] }) + }) + + test('plugin_validate resolves the open plugin from the snapshot', async () => { + const snapshot = { + localId: 'agent-probe', + pluginId: 'site.agent-probe', + files: [], + activeFile: null, + state: 'draft-changed', + activeVersion: null, + declaredPermissions: [], + grantedPermissions: [], + latestDiagnostics: null, + currentUser: { id: ownerId, displayName: 'Owner', email: 'o@example.com' }, + } + const result = await run('plugin_validate', {}, ctx(OWNER_CAPS, snapshot)) + expect(result.ok).toBe(true) + }) + + test('plugin_validate without any plugin in scope names the fix', async () => { + const result = await run('plugin_validate', {}, ctx(OWNER_CAPS)) + expect(result.ok).toBe(false) + expect(result.error).toContain('plugin_list_plugins') + }) + + test('first activation is a consent moment — the tool refuses', async () => { + const result = await run('plugin_activate', { localId: 'agent-probe' }, ctx(OWNER_CAPS)) + expect(result.ok).toBe(false) + expect(result.error).toContain('human consent') + expect(result.error).toContain('Build & activate') + }) + + test('same-grant rebuild activates without consent friction', async () => { + // Human first-activation via the HTTP route (step-up cookie from setupOwner). + const httpActivate = await harness.cms('/admin/api/cms/site-plugins/agent-probe/activate', { + method: 'POST', + cookie: ownerCookie, + }) + expect(httpActivate.status).toBe(200) + + // Change the source (same grants), then the agent rebuilds autonomously. + const shell = await getDraftSite(harness.db, MAIN_SCOPE) + if (!shell) throw new Error('no draft') + const files = shell.files.map((file) => + file.path === 'plugins/agent-probe/server/index.ts' + ? { ...file, content: `${file.content}\n// agent edit`, updatedAt: Date.now() } + : file, + ) + await saveDraftSite(harness.db, MAIN_SCOPE, { ...shell, files, updatedAt: Date.now() }) + + const result = await run('plugin_activate', { localId: 'agent-probe' }, ctx(OWNER_CAPS)) + expect(result.ok).toBe(true) + expect(result.data).toMatchObject({ activated: true, pluginId: 'site.agent-probe' }) + }) + + test('unchanged source re-activation is a skip', async () => { + const result = await run('plugin_activate', { localId: 'agent-probe' }, ctx(OWNER_CAPS)) + expect(result.ok).toBe(true) + expect(result.data).toMatchObject({ activated: false, skipped: true }) + }) + + test('plugin_docs serves an index and full topics', async () => { + const index = await run('plugin_docs', {}, ctx(OWNER_CAPS)) + expect(index.ok).toBe(true) + const { topics } = index.data as { topics: Array<{ topic: string; summary: string }> } + expect(topics.map((entry) => entry.topic)).toContain('admin-pages') + + const topic = await run('plugin_docs', { topic: 'admin-pages' }, ctx(OWNER_CAPS)) + expect(topic.ok).toBe(true) + const { content } = topic.data as { content: string } + // The exact contract the agent guessed wrong in live testing: app entries + // are JS modules default-exporting a React component. + expect(content).toContain('DEFAULT EXPORT must be a React component') + expect(content).toContain('"kind": "resource"') + + const unknown = await run('plugin_docs', { topic: 'nope' }, ctx(OWNER_CAPS)) + expect(unknown.ok).toBe(false) + expect(unknown.error).toContain('admin-pages') + }) + + test('plugin_list_plugins reports the runtime state', async () => { + const result = await run('plugin_list_plugins', {}, ctx(OWNER_CAPS)) + expect(result.ok).toBe(true) + const { sitePlugins } = result.data as { sitePlugins: Array<{ localId: string; state: string }> } + const entry = sitePlugins.find((plugin) => plugin.localId === 'agent-probe') + expect(entry?.state).toBe('active') + }) +}) diff --git a/src/__tests__/server/sitePluginBuild.test.ts b/src/__tests__/server/sitePluginBuild.test.ts new file mode 100644 index 000000000..49de44e39 --- /dev/null +++ b/src/__tests__/server/sitePluginBuild.test.ts @@ -0,0 +1,148 @@ +/** + * Server-side site plugin build — materialized workspace, shared builder + * core with import containment, validate-only mode, single-flight queue. + */ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildSitePlugin } from '../../../server/plugins/sitePlugins/build' +import type { SiteFile } from '@core/files/schemas' + +const file = (path: string, content: string): SiteFile => ({ + id: path, + path, + type: 'plugin', + content, + createdAt: 1, + updatedAt: 1, +}) + +const routesManifest = JSON.stringify({ name: 'Newsletter', permissions: ['cms.routes'] }) + +const serverEntry = [ + `export function activate(api) {`, + ` api.cms.routes.get('/status', 'plugins.read', () => ({ ok: true }))`, + `}`, +].join('\n') + +describe('buildSitePlugin', () => { + test('happy path writes plugin.json + server bundle under uploads', async () => { + const uploadsDir = await mkdtemp(join(tmpdir(), 'uploads-')) + try { + const result = await buildSitePlugin({ + localId: 'newsletter', + files: [ + file('plugins/newsletter/plugin.json', routesManifest), + file('plugins/newsletter/server/index.ts', serverEntry), + ], + previousVersion: null, + uploadsDir, + validateOnly: false, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.manifest.id).toBe('site.newsletter') + expect(result.manifest.version).toBe(`1.0.1+${result.contentHash}`) + expect(result.packageDir).toBe( + join(uploadsDir, 'plugins', 'site.newsletter', result.manifest.version), + ) + expect(existsSync(join(result.packageDir!, 'plugin.json'))).toBe(true) + expect(existsSync(join(result.packageDir!, 'server/index.js'))).toBe(true) + const bundle = await Bun.file(join(result.packageDir!, 'server/index.js')).text() + expect(bundle).toContain('__plugin_exports') + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) + + test('validate-only leaves uploads untouched and returns the modules bundle', async () => { + const uploadsDir = await mkdtemp(join(tmpdir(), 'uploads-')) + try { + const result = await buildSitePlugin({ + localId: 'kit', + files: [ + file('plugins/kit/plugin.json', JSON.stringify({ name: 'Kit', permissions: ['modules.register'] })), + file( + 'plugins/kit/modules/card.ts', + [ + `import { control, defineModule, html } from '@instatic/plugin-sdk'`, + `export default defineModule({`, + ` id: 'site.kit.card', name: 'Card', htmlTag: 'div',`, + ` defaults: { text: 'hi' },`, + ` schema: { text: control.text('Text') },`, + ` render: ({ props }) => ({ html: html\`
\${props.text}
\` }),`, + `})`, + ].join('\n'), + ), + ], + previousVersion: null, + uploadsDir, + validateOnly: true, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.packageDir).toBeUndefined() + expect(result.modulesBundle).toContain('site.kit.card') + expect(await readdir(uploadsDir)).toEqual([]) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) + + test('containment violation surfaces as a diagnostic', async () => { + const result = await buildSitePlugin({ + localId: 'evil', + files: [ + file('plugins/evil/plugin.json', JSON.stringify({ name: 'Evil', permissions: ['cms.routes'] })), + file('plugins/evil/server/index.ts', "import x from '../../../.env' with { type: 'text' }\nexport function activate() { return x }"), + ], + previousVersion: null, + validateOnly: true, + }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.diagnostics.join('\n')).toMatch(/outside the plugin workspace/) + }) + + test('forbidden node primitives surface as a diagnostic', async () => { + const result = await buildSitePlugin({ + localId: 'nodey', + files: [ + file('plugins/nodey/plugin.json', JSON.stringify({ name: 'Nodey', permissions: ['cms.routes'] })), + file('plugins/nodey/server/index.ts', "import { readFileSync } from 'node:fs'\nexport function activate() { return readFileSync('/etc/passwd') }"), + ], + previousVersion: null, + validateOnly: true, + }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.diagnostics.join('\n')).toMatch(/node:|not an allowed dependency|sandbox/i) + }) + + test('missing plugin.json is a diagnostic, not a crash', async () => { + const result = await buildSitePlugin({ + localId: 'ghost', + files: [file('plugins/ghost/server/index.ts', serverEntry)], + previousVersion: null, + validateOnly: true, + }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.diagnostics[0]).toContain('plugin.json is missing') + }) + + test('builds are single-flight per localId', async () => { + const files = [ + file('plugins/serial/plugin.json', routesManifest), + file('plugins/serial/server/index.ts', serverEntry), + ] + const [a, b] = await Promise.all([ + buildSitePlugin({ localId: 'serial', files, previousVersion: null, validateOnly: true }), + buildSitePlugin({ localId: 'serial', files, previousVersion: null, validateOnly: true }), + ]) + expect(a.ok).toBe(true) + expect(b.ok).toBe(true) + }) +}) diff --git a/src/__tests__/server/sitePluginExport.test.ts b/src/__tests__/server/sitePluginExport.test.ts new file mode 100644 index 000000000..7a9112fa4 --- /dev/null +++ b/src/__tests__/server/sitePluginExport.test.ts @@ -0,0 +1,97 @@ +/** + * Site plugin export/import — the site bundle carries plugin SOURCE + * (`type: 'plugin'` shell files) and never generated artifacts or runtime + * rows. On import the source lands as draft; the operator rebuilds and + * activates on the target instance so grants and secrets are reviewed there. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { unzipSync, strFromU8 } from 'fflate' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +import { getDraftSite } from '../../../server/repositories/site' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { getInstalledPlugin } from '../../../server/repositories/plugins' + +let source: CapabilityTestHarness +let target: CapabilityTestHarness +let sourceCookie: string +let targetCookie: string + +beforeAll(async () => { + source = await createCapabilityTestHarness() + target = await createCapabilityTestHarness() + sourceCookie = await source.setupOwner() + targetCookie = await target.setupOwner() +}) + +afterAll(async () => { + await source.cleanup() + await target.cleanup() +}) + +describe('site plugin export/import', () => { + test('export carries plugin source files, never generated artifacts', async () => { + const scaffold = await source.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: sourceCookie, + json: { name: 'Newsletter', localId: 'newsletter', template: 'routes' }, + }) + expect(scaffold.status).toBe(201) + + const res = await source.cms('/admin/api/cms/export', { cookie: sourceCookie }) + expect(res.status).toBe(200) + const archive = unzipSync(new Uint8Array(await res.arrayBuffer())) + + const manifestEntry = archive['.instatic/site-bundle.json'] + expect(manifestEntry).toBeDefined() + // The bundle manifest is this test's own fixture — parsed for assertions + // only, not consumed as typed data. + const manifest = JSON.parse(strFromU8(manifestEntry!)) as { + site?: { files?: Array<{ path: string; type: string }> } + } + const pluginFiles = (manifest.site?.files ?? []).filter((file) => file.type === 'plugin') + expect(pluginFiles.map((file) => file.path).sort()).toEqual([ + 'plugins/newsletter/plugin.json', + 'plugins/newsletter/server/index.ts', + ]) + + // Generated packages never enter the archive. + const entryNames = Object.keys(archive) + expect(entryNames.some((name) => name.includes('uploads/plugins'))).toBe(false) + }) + + test('import lands source as draft with NO runtime record', async () => { + const exportRes = await source.cms('/admin/api/cms/export', { cookie: sourceCookie }) + const archive = unzipSync(new Uint8Array(await exportRes.arrayBuffer())) + const bundle = JSON.parse(strFromU8(archive['.instatic/site-bundle.json']!)) as unknown + + const importRes = await target.cms('/admin/api/cms/import', { + method: 'POST', + cookie: targetCookie, + json: bundle, + }) + expect(importRes.status).toBe(200) + await readJson(importRes) + + const shell = await getDraftSite(target.db, MAIN_SCOPE) + const pluginFiles = shell?.files.filter((file) => file.type === 'plugin') ?? [] + expect(pluginFiles.map((file) => file.path).sort()).toEqual([ + 'plugins/newsletter/plugin.json', + 'plugins/newsletter/server/index.ts', + ]) + + // Powers never transfer — the operator rebuilds + activates on the target. + expect(await getInstalledPlugin(target.db, 'site.newsletter')).toBeNull() + + // And the imported draft is immediately buildable. + const validate = await target.cms('/admin/api/cms/site-plugins/newsletter/validate', { + method: 'POST', + cookie: targetCookie, + }) + expect(validate.status).toBe(200) + expect(await readJson(validate)).toEqual({ ok: true, diagnostics: [] }) + }) +}) diff --git a/src/__tests__/server/sitePluginLifecycle.test.ts b/src/__tests__/server/sitePluginLifecycle.test.ts new file mode 100644 index 000000000..1e979991a --- /dev/null +++ b/src/__tests__/server/sitePluginLifecycle.test.ts @@ -0,0 +1,381 @@ +/** + * Site plugin lifecycle endpoints — scaffold, validate, activation + * authority (plugins.install + step-up only on consent moments), rebuild + * skip, grant shrink/grow, and delete (runtime row + draft source). + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createCapabilityTestHarness, + expectForbidden, + expectStepUpRequired, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { getInstalledPlugin } from '../../../server/repositories/plugins' +import type { SitePluginSummary } from '@core/site-plugins' + +let harness: CapabilityTestHarness +let uploadsDir: string +let ownerCookie: string +let ownerEmail: string + +beforeAll(async () => { + uploadsDir = await mkdtemp(join(tmpdir(), 'site-plugin-uploads-')) + harness = await createCapabilityTestHarness({ uploadsDir }) + ownerCookie = await harness.setupOwner() + // setupOwner logs in the owner; capture a NON-stepped-up cookie too. + ownerEmail = '' +}) + +afterAll(async () => { + await harness.cleanup() + await rm(uploadsDir, { recursive: true, force: true }) +}) + +async function updateDraftFile(path: string, content: string): Promise { + const shell = await getDraftSite(harness.db, MAIN_SCOPE) + if (!shell) throw new Error('no draft site') + const files = shell.files.map((file) => + file.path === path ? { ...file, content, updatedAt: Date.now() } : file, + ) + await saveDraftSite(harness.db, MAIN_SCOPE, { ...shell, files, updatedAt: Date.now() }) +} + +describe('site plugin scaffold + validate', () => { + test('scaffold requires plugins.edit', async () => { + // Even a full site editor cannot scaffold — plugin authoring is its own + // capability, distinct from site-structure rights. + const viewer = await harness.createRoleUser({ + name: 'Viewer', + slug: 'viewer', + capabilities: [ + 'plugins.read', + 'site.read', + 'site.structure.edit', + 'site.content.edit', + 'site.style.edit', + ], + }) + const res = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: viewer.cookie, + json: { name: 'Newsletter', localId: 'newsletter', template: 'routes' }, + }) + await expectForbidden(res) + }) + + test('a plugins.edit-only developer can scaffold', async () => { + const developer = await harness.createRoleUser({ + name: 'Plugin Developer', + slug: 'plugin-developer', + capabilities: ['site.read', 'plugins.edit'], + }) + const res = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: developer.cookie, + json: { name: 'Dev Probe', localId: 'dev-probe', template: 'empty' }, + }) + expect(res.status).toBe(201) + }) + + test('scaffold creates plugin-typed draft files', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: ownerCookie, + json: { name: 'Newsletter', localId: 'newsletter', template: 'routes' }, + }) + expect(res.status).toBe(201) + const shell = await getDraftSite(harness.db, MAIN_SCOPE) + // Scope to this plugin's folder — the suite scaffolds other plugins too. + const pluginFiles = (shell?.files ?? []).filter( + (file) => file.type === 'plugin' && file.path.startsWith('plugins/newsletter/'), + ) + expect(pluginFiles.map((file) => file.path).sort()).toEqual([ + 'plugins/newsletter/plugin.json', + 'plugins/newsletter/server/index.ts', + ]) + }) + + test('duplicate scaffold is a 409', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: ownerCookie, + json: { name: 'Newsletter', localId: 'newsletter', template: 'empty' }, + }) + expect(res.status).toBe(409) + }) + + test('the scaffolded plugin passes validation with site.read only', async () => { + const viewer = await harness.createRoleUser({ + name: 'Site Reader', + slug: 'site-reader', + capabilities: ['site.read'], + }) + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/validate', { + method: 'POST', + cookie: viewer.cookie, + }) + expect(res.status).toBe(200) + const body = await readJson<{ ok: boolean; diagnostics: string[] }>(res) + expect(body).toEqual({ ok: true, diagnostics: [] }) + }) + + test('the list computes draft-changed for a never-built plugin', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins', { cookie: ownerCookie }) + expect(res.status).toBe(200) + const body = await readJson<{ sitePlugins: SitePluginSummary[] }>(res) + const entry = body.sitePlugins.find((plugin) => plugin.localId === 'newsletter') + expect(entry?.state).toBe('draft-changed') + expect(entry?.pluginId).toBe('site.newsletter') + expect(entry?.activeVersion).toBeNull() + }) +}) + +describe('site plugin activation authority', () => { + test('activate without plugins.install is forbidden', async () => { + const editor = await harness.createRoleUser({ + name: 'Site Editor', + slug: 'site-editor', + capabilities: ['site.read', 'site.structure.edit', 'site.content.edit', 'site.style.edit'], + }) + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: editor.cookie, + }) + await expectForbidden(res) + }) + + test('first activation requires step-up; with step-up it installs', async () => { + const installer = await harness.createRoleUser({ + name: 'Installer', + slug: 'installer', + capabilities: ['plugins.read', 'plugins.install', 'site.read'], + }) + + const noStepUp = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: installer.cookie, + }) + await expectStepUpRequired(noStepUp) + + const stepped = await harness.stepUp(installer.cookie) + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: stepped, + }) + expect(res.status).toBe(200) + const body = await readJson<{ plugin: { id: string; version: string; source: string } }>(res) + expect(body.plugin.id).toBe('site.newsletter') + expect(body.plugin.version).toStartWith('1.0.1+') + expect(body.plugin.source).toBe('site-local') + + // Package on disk, byte-compatible layout. + expect( + existsSync(join(uploadsDir, 'plugins', 'site.newsletter', body.plugin.version, 'plugin.json')), + ).toBe(true) + expect( + existsSync(join(uploadsDir, 'plugins', 'site.newsletter', body.plugin.version, 'server/index.js')), + ).toBe(true) + }) + + test('unchanged source activation is a skip', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: ownerCookie, + }) + expect(res.status).toBe(200) + const body = await readJson<{ skipped?: boolean; plugin: { version: string } }>(res) + expect(body.skipped).toBe(true) + expect(body.plugin.version).toStartWith('1.0.1+') + }) + + test('source change without grant change rebuilds WITHOUT step-up (upgrade path)', async () => { + await updateDraftFile( + 'plugins/newsletter/server/index.ts', + [ + `import type { ServerPluginModule } from '@instatic/plugin-sdk'`, + `const mod: ServerPluginModule = {`, + ` activate(api) {`, + ` api.cms.routes.get('/status', 'plugins.read', () => ({ ok: true, rev: 2 }))`, + ` },`, + `}`, + `export default mod`, + ].join('\n'), + ) + + const installer = await harness.createRoleUser({ + name: 'Installer 2', + slug: 'installer-2', + capabilities: ['plugins.read', 'plugins.install', 'site.read'], + }) + // NOT stepped up — same grants, no consent moment. + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: installer.cookie, + }) + expect(res.status).toBe(200) + const body = await readJson<{ + plugin: { version: string } + upgrade?: { fromVersion: string; toVersion: string } + }>(res) + expect(body.plugin.version).toStartWith('1.0.2+') + expect(body.upgrade?.fromVersion).toStartWith('1.0.1+') + expect(body.upgrade?.toVersion).toStartWith('1.0.2+') + }) + + test('adding a permission requires step-up again; grants follow declarations', async () => { + const shell = await getDraftSite(harness.db, MAIN_SCOPE) + const manifestFile = shell?.files.find((file) => file.path === 'plugins/newsletter/plugin.json') + expect(manifestFile).toBeDefined() + await updateDraftFile( + 'plugins/newsletter/plugin.json', + JSON.stringify({ + name: 'Newsletter', + description: '', + permissions: ['cms.routes', 'cms.routes.public'], + }), + ) + + const installer = await harness.createRoleUser({ + name: 'Installer 3', + slug: 'installer-3', + capabilities: ['plugins.read', 'plugins.install', 'site.read'], + }) + const noStepUp = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: installer.cookie, + }) + await expectStepUpRequired(noStepUp) + + const stepped = await harness.stepUp(installer.cookie) + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/activate', { + method: 'POST', + cookie: stepped, + }) + expect(res.status).toBe(200) + const row = await getInstalledPlugin(harness.db, 'site.newsletter') + expect(row?.kind).toBe('ok') + if (row?.kind === 'ok') { + expect(row.plugin.grantedPermissions.sort()).toEqual(['cms.routes', 'cms.routes.public']) + } + }) + + test('the list reports active after activation, with every retained build', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins', { cookie: ownerCookie }) + const body = await readJson<{ sitePlugins: SitePluginSummary[] }>(res) + const entry = body.sitePlugins.find((plugin) => plugin.localId === 'newsletter') + expect(entry?.state).toBe('active') + expect(entry?.activeVersion).toStartWith('1.0.3+') + // Three builds so far, all retained (the policy keeps five), newest first. + expect(entry?.revisions.map((revision) => revision.version.split('+')[0])).toEqual([ + '1.0.3', + '1.0.2', + '1.0.1', + ]) + for (const revision of entry?.revisions ?? []) { + expect(revision.builtAt).toBeGreaterThan(0) + } + }) + + test('rollback targets a retained build; a different grant set steps up', async () => { + const list = await readJson<{ sitePlugins: SitePluginSummary[] }>( + await harness.cms('/admin/api/cms/site-plugins', { cookie: ownerCookie }), + ) + const entry = list.sitePlugins.find((plugin) => plugin.localId === 'newsletter')! + const target = entry.revisions.find((revision) => revision.version.startsWith('1.0.2+'))! + + const installer = await harness.createRoleUser({ + name: 'Installer 4', + slug: 'installer-4', + capabilities: ['plugins.read', 'plugins.install', 'site.read'], + }) + + // An unknown version is refused before anything is touched. + const bogus = await harness.cms('/admin/api/cms/site-plugins/newsletter/rollback', { + method: 'POST', + cookie: installer.cookie, + json: { version: '1.0.99+deadbeef' }, + }) + expect(bogus.status).toBe(400) + + // 1.0.2 was granted [cms.routes]; the active 1.0.3 holds [cms.routes, + // cms.routes.public] — a grant change, so the consent moment applies. + const noStepUp = await harness.cms('/admin/api/cms/site-plugins/newsletter/rollback', { + method: 'POST', + cookie: installer.cookie, + json: { version: target.version }, + }) + await expectStepUpRequired(noStepUp) + + const stepped = await harness.stepUp(installer.cookie) + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter/rollback', { + method: 'POST', + cookie: stepped, + json: { version: target.version }, + }) + expect(res.status).toBe(200) + const body = await readJson<{ rolledBackTo: string; plugin: { version: string } }>(res) + expect(body.rolledBackTo).toBe(target.version) + expect(body.plugin.version).toBe(target.version) + + const row = await getInstalledPlugin(harness.db, 'site.newsletter') + expect(row?.kind).toBe('ok') + if (row?.kind === 'ok') { + expect(row.plugin.version).toBe(target.version) + expect(row.plugin.grantedPermissions).toEqual(['cms.routes']) + } + + // The newer build stays retained: rolling forward is a rollback too. + const after = await readJson<{ sitePlugins: SitePluginSummary[] }>( + await harness.cms('/admin/api/cms/site-plugins', { cookie: ownerCookie }), + ) + const afterEntry = after.sitePlugins.find((plugin) => plugin.localId === 'newsletter') + expect(afterEntry?.revisions.some((revision) => revision.version.startsWith('1.0.3+'))).toBe(true) + // The draft still declares cms.routes.public, which the rolled-back + // grant set lacks: that is a draft change like any other, and the + // review happens on the next activation click. + expect(afterEntry?.state).toBe('draft-changed') + expect(afterEntry?.newPermissions).toEqual(['cms.routes.public']) + + const alreadyActive = await harness.cms('/admin/api/cms/site-plugins/newsletter/rollback', { + method: 'POST', + cookie: stepped, + json: { version: target.version }, + }) + expect(alreadyActive.status).toBe(400) + }) +}) + +describe('site plugin delete', () => { + test('delete requires step-up, then removes row + assets + draft source', async () => { + const noStepUp = await harness.cms('/admin/api/cms/site-plugins/newsletter', { + method: 'DELETE', + cookie: await harness.sessionForEmail(ownerEmail || (await ownerEmailOf())), + }) + await expectStepUpRequired(noStepUp) + + const res = await harness.cms('/admin/api/cms/site-plugins/newsletter', { + method: 'DELETE', + cookie: ownerCookie, + }) + expect(res.status).toBe(200) + + expect(await getInstalledPlugin(harness.db, 'site.newsletter')).toBeNull() + expect(existsSync(join(uploadsDir, 'plugins', 'site.newsletter'))).toBe(false) + const shell = await getDraftSite(harness.db, MAIN_SCOPE) + expect(shell?.files.some((file) => file.path.startsWith('plugins/newsletter/'))).toBe(false) + }) +}) + +async function ownerEmailOf(): Promise { + const { rows } = await harness.db<{ email: string }>` + select email from users order by created_at asc limit 1 + ` + return rows[0]!.email +} diff --git a/src/__tests__/server/sitePluginPreviewPack.test.ts b/src/__tests__/server/sitePluginPreviewPack.test.ts new file mode 100644 index 000000000..2788c0767 --- /dev/null +++ b/src/__tests__/server/sitePluginPreviewPack.test.ts @@ -0,0 +1,62 @@ +/** + * Session-local draft canvas preview — the preview-pack route returns the + * validate-only modules bundle with no-store caching and registers NOTHING + * server-side (the plugin row stays untouched). + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +import { getInstalledPlugin } from '../../../server/repositories/plugins' + +let harness: CapabilityTestHarness +let ownerCookie: string + +beforeAll(async () => { + harness = await createCapabilityTestHarness() + ownerCookie = await harness.setupOwner() + const scaffold = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: ownerCookie, + json: { name: 'Kit', localId: 'kit', template: 'module' }, + }) + expect(scaffold.status).toBe(201) +}) + +afterAll(async () => { + await harness.cleanup() +}) + +describe('site plugin preview pack', () => { + test('returns the draft modules bundle with no-store, registering nothing', async () => { + const res = await harness.cms('/admin/api/cms/site-plugins/kit/preview-pack.js', { + cookie: ownerCookie, + }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/javascript') + expect(res.headers.get('cache-control')).toBe('no-store') + const bundle = await res.text() + expect(bundle).toContain('site.kit.kit') + + // Preview is session-local by construction — no runtime row appears. + expect(await getInstalledPlugin(harness.db, 'site.kit')).toBeNull() + }) + + test('404s with diagnostics context for a plugin without modules', async () => { + const scaffold = await harness.cms('/admin/api/cms/site-plugins', { + method: 'POST', + cookie: ownerCookie, + json: { name: 'Backend', localId: 'backend', template: 'routes' }, + }) + expect(scaffold.status).toBe(201) + + const res = await harness.cms('/admin/api/cms/site-plugins/backend/preview-pack.js', { + cookie: ownerCookie, + }) + expect(res.status).toBe(404) + const body = await readJson<{ error: string }>(res) + expect(body.error).toContain('no module pack') + }) +}) diff --git a/src/__tests__/server/sitePluginRetention.test.ts b/src/__tests__/server/sitePluginRetention.test.ts new file mode 100644 index 000000000..0bf45ad92 --- /dev/null +++ b/src/__tests__/server/sitePluginRetention.test.ts @@ -0,0 +1,88 @@ +/** + * Site plugin revision retention — keep the five highest builds plus the + * active one, list the retained builds newest first, delete the rest. + */ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + RETAINED_REVISIONS, + listSitePluginRevisions, + sweepSitePluginRevisions, +} from '../../../server/plugins/sitePlugins/retention' + +async function uploadsWithRevisions(versions: string[]): Promise { + const uploadsDir = await mkdtemp(join(tmpdir(), 'retention-')) + for (const version of versions) { + const dir = join(uploadsDir, 'plugins', 'site.demo', version) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'plugin.json'), '{}', 'utf8') + } + return uploadsDir +} + +const SEVEN = ['1.0.1+a', '1.0.2+b', '1.0.3+c', '1.0.4+d', '1.0.5+e', '1.0.6+f', '1.0.7+g'] + +describe('sweepSitePluginRevisions', () => { + test(`keeps the ${RETAINED_REVISIONS} highest builds when the newest is active`, async () => { + const uploadsDir = await uploadsWithRevisions(SEVEN) + try { + const removed = await sweepSitePluginRevisions(uploadsDir, 'site.demo', '1.0.7+g') + expect(removed.sort()).toEqual(['1.0.1+a', '1.0.2+b']) + const remaining = (await readdir(join(uploadsDir, 'plugins', 'site.demo'))).sort() + expect(remaining).toEqual(['1.0.3+c', '1.0.4+d', '1.0.5+e', '1.0.6+f', '1.0.7+g']) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) + + test('an active build outside the top five is kept too (deep rollback)', async () => { + const uploadsDir = await uploadsWithRevisions(SEVEN) + try { + const removed = await sweepSitePluginRevisions(uploadsDir, 'site.demo', '1.0.1+a') + expect(removed).toEqual(['1.0.2+b']) + const remaining = (await readdir(join(uploadsDir, 'plugins', 'site.demo'))).sort() + expect(remaining).toEqual(['1.0.1+a', '1.0.3+c', '1.0.4+d', '1.0.5+e', '1.0.6+f', '1.0.7+g']) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) + + test('after a rollback the newer builds stay retained (roll forward is possible)', async () => { + const uploadsDir = await uploadsWithRevisions(['1.0.1+a', '1.0.2+b', '1.0.3+c']) + try { + expect(await sweepSitePluginRevisions(uploadsDir, 'site.demo', '1.0.2+b')).toEqual([]) + const remaining = (await readdir(join(uploadsDir, 'plugins', 'site.demo'))).sort() + expect(remaining).toEqual(['1.0.1+a', '1.0.2+b', '1.0.3+c']) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) + + test('entries that are not version-shaped are swept; a missing plugin dir is a no-op', async () => { + const uploadsDir = await uploadsWithRevisions(['1.0.1+a', 'stray']) + try { + expect(await sweepSitePluginRevisions(uploadsDir, 'site.demo', '1.0.1+a')).toEqual(['stray']) + expect(await sweepSitePluginRevisions(uploadsDir, 'site.ghost', '1.0.1+a')).toEqual([]) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) +}) + +describe('listSitePluginRevisions', () => { + test('lists retained builds newest first with a build time', async () => { + const uploadsDir = await uploadsWithRevisions(['1.0.2+b', '1.0.10+j', '1.0.5+e']) + try { + const revisions = await listSitePluginRevisions(uploadsDir, 'site.demo') + expect(revisions.map((revision) => revision.version)).toEqual(['1.0.10+j', '1.0.5+e', '1.0.2+b']) + for (const revision of revisions) { + expect(revision.builtAt).toBeGreaterThan(Date.now() - 60_000) + } + expect(await listSitePluginRevisions(uploadsDir, 'site.ghost')).toEqual([]) + } finally { + await rm(uploadsDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/__tests__/server/sitePluginWritePolicy.test.ts b/src/__tests__/server/sitePluginWritePolicy.test.ts new file mode 100644 index 000000000..5d7b68e0a --- /dev/null +++ b/src/__tests__/server/sitePluginWritePolicy.test.ts @@ -0,0 +1,149 @@ +/** + * plugins.edit write policy — `validateSiteWriteDiff`'s `plugins` change + * category. Plugin-source files (`type: 'plugin'`) are their own trust + * domain: every change to them requires `plugins.edit`, never a site + * capability — and site capabilities alone never authorize one. Shared by + * both write transports (HTTP save + collab update guard), so this pure + * policy test covers the socket path too. + */ +import { describe, expect, test } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import type { SiteFile } from '@core/files/schemas' +import type { SiteShell } from '@core/page-tree' +import { + ForbiddenSiteChangeError, + validateSiteWriteDiff, +} from '../../../server/writePolicy/siteDiff' + +const ALL_SITE_CAPS: CoreCapability[] = [ + 'site.structure.edit', + 'site.content.edit', + 'site.style.edit', +] + +function shell(files: SiteFile[]): SiteShell { + return { + id: 'project_1', + name: 'CMS Site', + files, + visualComponents: [], + breakpoints: [{ id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }], + settings: { shortcuts: {} }, + styleRules: {}, + packageJson: { dependencies: {}, devDependencies: {} }, + runtime: { dependencyLock: { version: 1, packages: {}, updatedAt: 0 }, scripts: {} }, + createdAt: 1000, + updatedAt: 2000, + } +} + +function pluginFile(overrides: Partial = {}): SiteFile { + return { + id: 'f1', + path: 'plugins/newsletter/server/index.ts', + type: 'plugin', + content: 'export {}', + updatedAt: 0, + ...overrides, + } +} + +function expectForbiddenPluginChange(fn: () => void): void { + let caught: unknown + try { + fn() + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(ForbiddenSiteChangeError) + expect((caught as ForbiddenSiteChangeError).kind).toBe('plugins') +} + +describe('validateSiteWriteDiff — plugins category', () => { + test('a full site writer WITHOUT plugins.edit cannot add a plugin file', () => { + expectForbiddenPluginChange(() => + validateSiteWriteDiff(shell([]), shell([pluginFile()]), ALL_SITE_CAPS), + ) + }) + + test('a full site writer WITHOUT plugins.edit cannot edit plugin content', () => { + expectForbiddenPluginChange(() => + validateSiteWriteDiff( + shell([pluginFile()]), + shell([pluginFile({ content: 'export const x = 1' })]), + ALL_SITE_CAPS, + ), + ) + }) + + test('a full site writer WITHOUT plugins.edit cannot delete or rename plugin files', () => { + expectForbiddenPluginChange(() => + validateSiteWriteDiff(shell([pluginFile()]), shell([]), ALL_SITE_CAPS), + ) + expectForbiddenPluginChange(() => + validateSiteWriteDiff( + shell([pluginFile()]), + shell([pluginFile({ path: 'plugins/newsletter/server/renamed.ts' })]), + ALL_SITE_CAPS, + ), + ) + }) + + test('retyping across the plugin boundary requires plugins.edit from either side', () => { + // plugin → script: the file leaves the plugin domain. + expectForbiddenPluginChange(() => + validateSiteWriteDiff( + shell([pluginFile()]), + shell([pluginFile({ type: 'script', path: 'src/scripts/escaped.ts' })]), + ALL_SITE_CAPS, + ), + ) + // script → plugin: the file enters the plugin domain. + expectForbiddenPluginChange(() => + validateSiteWriteDiff( + shell([pluginFile({ type: 'script', path: 'src/scripts/escaped.ts' })]), + shell([pluginFile()]), + ALL_SITE_CAPS, + ), + ) + }) + + test('plugins.edit alone authorizes plugin-file changes and nothing else', () => { + const caps: CoreCapability[] = ['plugins.edit'] + // Full plugin-file lifecycle passes. + validateSiteWriteDiff(shell([]), shell([pluginFile()]), caps) + validateSiteWriteDiff( + shell([pluginFile()]), + shell([pluginFile({ content: 'export const x = 1' })]), + caps, + ) + validateSiteWriteDiff(shell([pluginFile()]), shell([]), caps) + + // A style-file content edit is still rejected — plugins.edit grants no + // site rights. + const styleFile: SiteFile = { + id: 'f2', + path: 'src/styles/site.css', + type: 'style', + content: 'body {}', + updatedAt: 0, + } + expect(() => + validateSiteWriteDiff( + shell([styleFile]), + shell([{ ...styleFile, content: 'body { margin: 0 }' }]), + caps, + ), + ).toThrow(ForbiddenSiteChangeError) + }) + + test('the full-writer fast path requires plugins.edit too', () => { + // With all four capabilities the diff is skipped entirely — any change + // passes, including plugin files. + validateSiteWriteDiff( + shell([]), + shell([pluginFile()]), + [...ALL_SITE_CAPS, 'plugins.edit'], + ) + }) +}) diff --git a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx index ac4bb1435..3c6551098 100644 --- a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx +++ b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx @@ -172,7 +172,7 @@ describe('SiteExplorerPanel', () => { const panel = screen.getByTestId('site-explorer-panel') expect(within(panel).getByRole('heading', { name: 'Styles' })).toBeDefined() - expect(within(panel).getByRole('heading', { name: 'Scripts' })).toBeDefined() + expect(within(panel).getByRole('heading', { name: 'Frontend scripts' })).toBeDefined() expect(within(panel).queryByRole('heading', { name: 'Pages' })).toBeNull() expect(within(panel).queryByRole('heading', { name: 'Components' })).toBeNull() @@ -266,7 +266,7 @@ describe('SiteExplorerPanel', () => { render() const codePanel = screen.getByTestId('site-explorer-panel') - const scriptsTree = within(codePanel).getByRole('tree', { name: 'Scripts' }) + const scriptsTree = within(codePanel).getByRole('tree', { name: 'Frontend scripts' }) expect(within(scriptsTree).getByRole('button', { name: 'assets' })).toBeDefined() expect(within(scriptsTree).getByRole('button', { name: 'js' })).toBeDefined() }) diff --git a/src/__tests__/sitePlugins/sitePluginRoute.test.ts b/src/__tests__/sitePlugins/sitePluginRoute.test.ts new file mode 100644 index 000000000..4ba5b4b98 --- /dev/null +++ b/src/__tests__/sitePlugins/sitePluginRoute.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'bun:test' +import { sitePluginRoute } from '@core/plugin-sdk' + +describe('sitePluginRoute', () => { + test('resolves to the reserved plugin runtime route', () => { + expect(sitePluginRoute('newsletter', '/subscribe')) + .toBe('/admin/api/cms/plugins/site.newsletter/runtime/subscribe') + expect(sitePluginRoute('newsletter', 'subscribe')) + .toBe('/admin/api/cms/plugins/site.newsletter/runtime/subscribe') + }) +}) diff --git a/src/__tests__/sitePlugins/sourceModel.test.ts b/src/__tests__/sitePlugins/sourceModel.test.ts new file mode 100644 index 000000000..d92448377 --- /dev/null +++ b/src/__tests__/sitePlugins/sourceModel.test.ts @@ -0,0 +1,221 @@ +/** + * Site plugin source model — discovery by folder convention, author-manifest + * validation (derived fields rejected), runtime manifest derivation, and the + * content hash the generated version carries. + */ +import { describe, expect, test } from 'bun:test' +import { + computeSitePluginContentHash, + contentHashOfVersion, + computeSitePluginState, + deriveSitePluginManifest, + discoverSitePlugins, + nextSitePluginVersion, +} from '@core/site-plugins' +import type { SiteFile } from '@core/files/schemas' + +const file = (path: string, content: string, type: SiteFile['type'] = 'plugin'): SiteFile => ({ + id: path, + path, + type, + content, + createdAt: 1, + updatedAt: 1, +}) + +const draftManifest = JSON.stringify({ + name: 'Newsletter', + description: 'Email capture', + permissions: ['cms.routes', 'cms.routes.public'], +}) + +describe('site plugin discovery', () => { + test('discovers plugins by folder convention, ignoring non-plugin files', () => { + const found = discoverSitePlugins([ + file('plugins/newsletter/plugin.json', draftManifest), + file('plugins/newsletter/server/index.ts', 'export function activate() {}'), + file('src/scripts/fx.ts', '', 'script'), + file('plugins/analytics/plugin.json', draftManifest), + ]) + expect(found.map((p) => p.localId)).toEqual(['analytics', 'newsletter']) + expect(found[1]!.files).toHaveLength(2) + expect(found[1]!.manifestFile?.path).toBe('plugins/newsletter/plugin.json') + }) + + test('rejects invalid local ids', () => { + expect(() => + discoverSitePlugins([file('plugins/News.Letter/plugin.json', draftManifest)]), + ).toThrow(/local id/i) + }) +}) + +describe('site plugin manifest derivation', () => { + test('derives id, version, apiVersion, entrypoints, assetBasePath', () => { + const derived = deriveSitePluginManifest({ + localId: 'newsletter', + draftManifestJson: draftManifest, + files: [file('plugins/newsletter/server/index.ts', '')], + previousVersion: '1.0.6+aaaa1111', + contentHash: 'bbbb2222', + }) + expect(derived.id).toBe('site.newsletter') + expect(derived.version).toBe('1.0.7+bbbb2222') + expect(derived.apiVersion).toBeGreaterThanOrEqual(1) + expect(derived.entrypoints?.server).toBe('server/index.js') + expect(derived.assetBasePath).toBe('/uploads/plugins/site.newsletter/1.0.7+bbbb2222') + }) + + test('first build starts the counter at 1', () => { + const derived = deriveSitePluginManifest({ + localId: 'newsletter', + draftManifestJson: draftManifest, + files: [file('plugins/newsletter/server/index.ts', '')], + previousVersion: null, + contentHash: 'cccc3333', + }) + expect(derived.version).toBe('1.0.1+cccc3333') + }) + + test('derives module entrypoints and pack from folder convention', () => { + const derived = deriveSitePluginManifest({ + localId: 'kit', + draftManifestJson: JSON.stringify({ name: 'Kit', permissions: ['modules.register'] }), + files: [ + file('plugins/kit/plugin.json', ''), + file('plugins/kit/modules/card.ts', ''), + file('plugins/kit/pack/site.json', '{}'), + ], + previousVersion: null, + contentHash: 'dddd4444', + }) + expect(derived.entrypoints?.modules).toBe('modules/index.js') + expect(derived.pack?.path).toBe('pack/site.json') + }) + + test('rewrites frontend asset source paths to built paths', () => { + const derived = deriveSitePluginManifest({ + localId: 'tracker', + draftManifestJson: JSON.stringify({ + name: 'Tracker', + permissions: ['frontend.assets'], + frontend: { assets: [{ kind: 'script', src: 'frontend/tracker.ts' }] }, + }), + files: [file('plugins/tracker/frontend/tracker.ts', '')], + previousVersion: null, + contentHash: 'eeee5555', + }) + const asset = derived.frontend?.assets[0] + expect(asset && asset.kind === 'script' ? asset.src : null).toBe('frontend/tracker.js') + }) + + test('rewrites and verifies adminPages app entries', () => { + const appManifest = (entry: string) => + JSON.stringify({ + name: 'CRM', + permissions: ['admin.navigation', 'editor.code'], + adminPages: [ + { + id: 'customers', + title: 'Customers', + content: { kind: 'app', heading: 'Customers', entry }, + }, + ], + }) + const files = [ + file('plugins/crm/plugin.json', ''), + file('plugins/crm/frontend/customers.tsx', ''), + ] + + // Source path rewrites to the built bundle. + const derived = deriveSitePluginManifest({ + localId: 'crm', + draftManifestJson: appManifest('frontend/customers.tsx'), + files, + previousVersion: null, + contentHash: 'abcd1234', + }) + const page = derived.adminPages[0] + expect(page && page.content.kind === 'app' ? page.content.entry : null).toBe( + 'frontend/customers.js', + ) + + // A non-JS entry fails at BUILD time (used to only fail at runtime). + expect(() => + deriveSitePluginManifest({ + localId: 'crm', + draftManifestJson: appManifest('frontend/customers.html'), + files: [...files, file('plugins/crm/frontend/customers.html', '')], + previousVersion: null, + contentHash: 'abcd1234', + }), + ).toThrow(/must be a JS module/i) + + // A dangling entry (no matching source) fails too. + expect(() => + deriveSitePluginManifest({ + localId: 'crm', + draftManifestJson: appManifest('frontend/missing.ts'), + files, + previousVersion: null, + contentHash: 'abcd1234', + }), + ).toThrow(/no matching source/i) + }) + + test('rejects author-set derived fields', () => { + const withId = JSON.stringify({ id: 'acme.evil', name: 'X', permissions: [] }) + expect(() => + deriveSitePluginManifest({ + localId: 'newsletter', + draftManifestJson: withId, + files: [], + previousVersion: null, + contentHash: 'ffff6666', + }), + ).toThrow(/derived by the build/i) + }) +}) + +describe('content hash + version', () => { + test('content hash is stable and order-independent', () => { + const a = [file('plugins/n/plugin.json', draftManifest), file('plugins/n/server/index.ts', 'x')] + const b = [...a].reverse() + expect(computeSitePluginContentHash(a)).toBe(computeSitePluginContentHash(b)) + const changed = [file('plugins/n/plugin.json', draftManifest), file('plugins/n/server/index.ts', 'y')] + expect(computeSitePluginContentHash(changed)).not.toBe(computeSitePluginContentHash(a)) + }) + + test('version round-trips its content hash', () => { + const hash = computeSitePluginContentHash([file('plugins/n/plugin.json', draftManifest)]) + const version = nextSitePluginVersion(null, hash) + expect(contentHashOfVersion(version)).toBe(hash) + expect(nextSitePluginVersion(version, 'aaaa')).toBe(`1.0.2+aaaa`) + }) +}) + +describe('runtime state machine', () => { + const base = { + hasDraftSource: true, + row: { version: '1.0.3+abc', lifecycleStatus: 'active' as const, enabled: true }, + manifestError: null, + draftContentHash: 'abc', + activeContentHash: 'abc', + } + + test('precedence: source-missing > runtime-error > disabled > build-failed > draft-changed > active', () => { + expect(computeSitePluginState(base)).toBe('active') + expect(computeSitePluginState({ ...base, draftContentHash: 'zzz' })).toBe('draft-changed') + expect(computeSitePluginState({ ...base, manifestError: 'bad json' })).toBe('build-failed') + expect( + computeSitePluginState({ + ...base, + row: { ...base.row, enabled: false, lifecycleStatus: 'disabled' }, + }), + ).toBe('disabled') + expect( + computeSitePluginState({ ...base, row: { ...base.row, lifecycleStatus: 'error' } }), + ).toBe('runtime-error') + expect(computeSitePluginState({ ...base, hasDraftSource: false })).toBe('source-missing') + expect(computeSitePluginState({ ...base, row: null })).toBe('draft-changed') + }) +}) diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts index cececd737..e02f41b85 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -592,7 +592,10 @@ describe('Toolbar — structural requirements', () => { expect(src).toContain(' { diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index 6d539e910..35d813d92 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -106,6 +106,10 @@ const DataPage = prewarmedLazy( () => import('./pages/data/DataPage').then((m) => ({ default: m.DataPage })), { displayName: 'DataPage' }, ) +const SitePluginIdePage = prewarmedLazy( + () => import('./pages/plugins/ide/SitePluginIdePage').then((m) => ({ default: m.SitePluginIdePage })), + { displayName: 'SitePluginIdePage' }, +) const SiteImportModal = lazy(() => import('./modals/SiteImport').then((m) => ({ default: m.SiteImportModal })), @@ -157,6 +161,7 @@ if (typeof window !== 'undefined') { pathname.startsWith('/admin/content') ? ContentPage : pathname.startsWith('/admin/data') ? DataPage : pathname.startsWith('/admin/media') ? MediaPage : + pathname.startsWith('/admin/plugins/develop/') ? SitePluginIdePage : pathname.startsWith('/admin/plugins/') ? PluginPage : pathname.startsWith('/admin/plugins') ? PluginsPage : pathname.startsWith('/admin/users') ? UsersPage : @@ -191,6 +196,7 @@ const ALL_WORKSPACE_PAGES = [ AiPage, AccountPage, PluginPage, + SitePluginIdePage, ] function pageForSection(section: AdminWorkspace) { @@ -204,6 +210,7 @@ function pageForSection(section: AdminWorkspace) { section === 'ai' ? AiPage : section === 'branchReview' ? SitePage : section === 'pluginPage' ? PluginPage : + section === 'pluginIde' ? SitePluginIdePage : section === 'account' ? AccountPage : DashboardPage ) @@ -330,6 +337,7 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat section === 'ai' ? : section === 'branchReview' ? : section === 'pluginPage' ? : + section === 'pluginIde' ? : section === 'account' ? : } diff --git a/src/admin/access.ts b/src/admin/access.ts index 666643390..a35fcb11d 100644 --- a/src/admin/access.ts +++ b/src/admin/access.ts @@ -218,6 +218,15 @@ export function canConfigurePlugins(user: CmsCurrentUser | null): boolean { return hasCapability(user, 'plugins.configure') } +/** + * Caller can author site-plugin source in the Plugin IDE — scaffold plugins + * and create/edit/rename/delete files under `plugins//` in the + * draft. Authoring only: code runs after a `plugins.install` activation. + */ +export function canEditPlugins(user: CmsCurrentUser | null): boolean { + return hasCapability(user, 'plugins.edit') +} + /** Caller can install, upgrade, uninstall, and re-sync plugin packs. */ export function canInstallPlugins(user: CmsCurrentUser | null): boolean { return hasCapability(user, 'plugins.install') @@ -287,6 +296,10 @@ export function canAccessWorkspace(user: CmsCurrentUser | null, workspace: Admin case 'plugins': case 'pluginPage': return canAccessPluginsWorkspace(user) + case 'pluginIde': + // Authoring site plugin source is site-developer work — activation + // (the power grant) is separately gated by plugins.install. + return hasCapability(user, 'site.read') case 'users': return canAccessUsersWorkspace(user) case 'ai': @@ -319,6 +332,7 @@ export function workspacePath(workspace: AdminWorkspace): string { case 'media': return '/admin/media' case 'plugins': + case 'pluginIde': return '/admin/plugins' case 'users': return '/admin/users' diff --git a/src/admin/ai/createScopedAgentStore.ts b/src/admin/ai/createScopedAgentStore.ts new file mode 100644 index 000000000..97dcc83dd --- /dev/null +++ b/src/admin/ai/createScopedAgentStore.ts @@ -0,0 +1,43 @@ +/** + * A standalone Zustand store holding ONLY the AgentSlice — the shape the + * hook-based workspaces (Content, Plugin IDE) compose per page mount. + * + * Why standalone: those workspaces are built on React hooks, not Zustand, + * so there is no parent store to compose the slice into, and building a + * whole workspace store just for the agent would be overkill. + * + * Why per mount (not module-level like useEditorStore): the pages mount and + * unmount as the user navigates; rebuilding the store each mount keeps + * memory in check and makes sure stale snapshot closures do not survive a + * logout or user swap. The site editor's store is module-level because the + * editor session is the entire admin lifetime, which does not apply here. + * + * The `as unknown as …` cast bridges the slice factory's site-editor-shaped + * return type (`EditorStoreSliceCreator`, typed for the combined + * site store) into a slice-only store. The slice only ever touches + * AgentSlice keys at runtime, so the widening is structurally safe and + * beats duplicating the factory per store shape. + */ +import { create, type StateCreator } from 'zustand' +import { mutative } from 'zustand-mutative' +import { subscribeWithSelector } from 'zustand/middleware' +import { createAgentSlice, type AgentSlice, type AgentSliceConfig } from '@site/agent' + +export function createScopedAgentStore(config: AgentSliceConfig) { + const sliceCreator = createAgentSlice(config) as unknown as StateCreator< + AgentSlice, + [['zustand/mutative', never]], + [], + AgentSlice + > + return create()( + subscribeWithSelector( + mutative( + (...args) => ({ + ...sliceCreator(...args), + }), + { enableAutoFreeze: true }, + ), + ), + ) +} diff --git a/src/admin/ai/useMcpWorkspaceBridge.ts b/src/admin/ai/useMcpWorkspaceBridge.ts index 7245225f4..1b34fe8ba 100644 --- a/src/admin/ai/useMcpWorkspaceBridge.ts +++ b/src/admin/ai/useMcpWorkspaceBridge.ts @@ -35,7 +35,7 @@ const BridgeEventSchema = Type.Union([ }), ]) -export type McpWorkspaceScope = 'site' | 'content' +export type McpWorkspaceScope = 'site' | 'content' | 'plugin' export type McpToolDispatcher = ( toolName: string, input: unknown, diff --git a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx index e9c0035a1..4e95c2523 100644 --- a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx +++ b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx @@ -33,7 +33,7 @@ const SettingsModal = lazy(() => import('@admin/modals/Settings/SettingsModal').then((m) => ({ default: m.SettingsModal })), ) -type WorkspaceCanvasSection = Extract +type WorkspaceCanvasSection = Extract interface AdminWorkspaceCanvasLayoutProps { workspace: WorkspaceCanvasSection diff --git a/src/admin/pages/content/agent/ContentAgentMount.tsx b/src/admin/pages/content/agent/ContentAgentMount.tsx index 2dca58651..e8a0bba42 100644 --- a/src/admin/pages/content/agent/ContentAgentMount.tsx +++ b/src/admin/pages/content/agent/ContentAgentMount.tsx @@ -1,15 +1,16 @@ /** The docked Content agent UI. Tool registration lives at ContentPage level. */ import { useEffect, useState } from 'react' import { AgentStoreProvider } from '@admin/ai/AgentStoreContext' +import { createScopedAgentStore } from '@admin/ai/createScopedAgentStore' import { AgentPanel } from '@site/panels/AgentPanel' -import { createContentAgentStore } from './contentAgentStore' +import { contentAgentSliceConfig } from './agentSliceConfig.content' interface ContentAgentMountProps { isVisible: boolean } export function ContentAgentMount({ isVisible }: ContentAgentMountProps) { - const [store] = useState(() => createContentAgentStore()) + const [store] = useState(() => createScopedAgentStore(contentAgentSliceConfig)) useEffect(() => { if (isVisible) store.getState().openAgent() diff --git a/src/admin/pages/content/agent/contentAgentStore.ts b/src/admin/pages/content/agent/contentAgentStore.ts deleted file mode 100644 index 67886ddcc..000000000 --- a/src/admin/pages/content/agent/contentAgentStore.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Content-workspace agent store — a small standalone Zustand instance - * holding ONLY the AgentSlice. Composed per ContentPage mount via - * `createContentAgentStore(currentUser)`. - * - * Why standalone (not part of a bigger content store): the content - * workspace is built on React hooks, not Zustand, so there's no parent - * store to compose into. Building a content workspace store JUST for the - * agent would be overkill — a per-mount tiny Zustand instance with one - * slice is the smallest correct shape. - * - * Why per-mount (not module-level like useEditorStore): the content page - * mounts/unmounts as the user navigates between admin pages; rebuilding - * the store each mount keeps memory in check and ensures stale snapshot - * closures don't survive a logout / user-swap. The site editor's store - * is module-level because the editor session is the entire admin - * lifetime, which doesn't apply here. - */ -import { create, type StateCreator } from 'zustand' -import { mutative } from 'zustand-mutative' -import { subscribeWithSelector } from 'zustand/middleware' -import { - createAgentSlice, - type AgentSlice, -} from '@site/agent' -import { contentAgentSliceConfig } from './agentSliceConfig.content' - -type ContentAgentStore = AgentSlice - -/** - * Build a fresh Zustand store for the content workspace's agent panel. - * No parameters — the agent's view of the world is reactive via the - * registered ContentBridgeHandle, not closed-over at store-creation time. - * - * The `as unknown as ...` cast bridges the slice's site-editor-shaped - * return type (`EditorStoreSliceCreator` — typed for the - * combined site store) into our slice-only store. The slice only ever - * touches AgentSlice keys at runtime, so the cast is structurally safe; - * we accept the type widening here to avoid duplicating the slice - * factory's logic for each store shape. - */ -export function createContentAgentStore() { - const sliceCreator = createAgentSlice(contentAgentSliceConfig) as unknown as - StateCreator - return create()( - subscribeWithSelector( - mutative( - (...args) => ({ - ...sliceCreator(...args), - }), - { enableAutoFreeze: true }, - ), - ), - ) -} diff --git a/src/admin/pages/plugins/PluginsPage.module.css b/src/admin/pages/plugins/PluginsPage.module.css index f1444b2db..1eabfb111 100644 --- a/src/admin/pages/plugins/PluginsPage.module.css +++ b/src/admin/pages/plugins/PluginsPage.module.css @@ -258,3 +258,4 @@ grid-template-columns: 1fr; } } + diff --git a/src/admin/pages/plugins/PluginsPage.tsx b/src/admin/pages/plugins/PluginsPage.tsx index 85f38d56a..d86436291 100644 --- a/src/admin/pages/plugins/PluginsPage.tsx +++ b/src/admin/pages/plugins/PluginsPage.tsx @@ -1,16 +1,24 @@ +import { useState } from 'react' import { Button } from '@ui/components/Button' import { UploadIcon } from 'pixel-art-icons/icons/upload' +import { CodeIcon } from 'pixel-art-icons/icons/code' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' +import { useNavigate } from '@admin/lib/routing' import { PluginCard } from './components/PluginCard/PluginCard' +import { DraftSitePluginCard } from './components/DraftSitePluginCard' +import { NewSitePluginDialog } from './components/NewSitePluginDialog' import { PluginRemoveDialog } from './components/PluginRemoveDialog/PluginRemoveDialog' import { PermissionReviewSection } from './components/PermissionReviewSection' import { PluginSettingsDialog } from './components/PluginSettingsDialog/PluginSettingsDialog' import { PluginSchedulesDialog } from './components/PluginSchedulesDialog/PluginSchedulesDialog' import { isSandboxRelatedError, usePluginsWorkspace } from './hooks/usePluginsWorkspace' +import { useSitePlugins } from './hooks/useSitePlugins' +import { localIdFromSitePluginId } from '@core/site-plugins' import { notifyCmsPluginsChanged } from './utils/pluginEvents' import { useAuthenticatedAdminUser } from '@admin/sessionContext' import { canConfigurePlugins, + canEditPlugins, canInstallPlugins, canManagePluginLifecycle, } from '@admin/access' @@ -24,9 +32,11 @@ const SKELETON_CARD_COUNT = 3 export function PluginsPage() { const currentUser = useAuthenticatedAdminUser() + const navigate = useNavigate() const canConfigure = canConfigurePlugins(currentUser) const canInstall = canInstallPlugins(currentUser) const canManageLifecycle = canManagePluginLifecycle(currentUser) + const canCreateSitePlugins = canEditPlugins(currentUser) const vm = usePluginsWorkspace() const { fileInputRef, @@ -43,31 +53,59 @@ export function PluginsPage() { removeFailure, } = vm + const { sitePlugins } = useSitePlugins() + const [newPluginOpen, setNewPluginOpen] = useState(false) + const openIde = (localId: string) => navigate(`/admin/plugins/develop/${localId}`) + + // ONE list: installed plugins (any provenance) plus site plugins that only + // exist as draft source — an activated site plugin is already an installed + // row, so drafts are the only entries the installed payload can't show. + const installedIds = new Set(payload.plugins.map((plugin) => plugin.id)) + const draftOnlySitePlugins = (sitePlugins ?? []).filter( + (plugin) => !installedIds.has(plugin.pluginId), + ) + const listEmpty = !loading && payload.plugins.length === 0 && draftOnlySitePlugins.length === 0 + return ( - - void vm.handleUpload(event)} - /> + {canCreateSitePlugins && ( + + )} + {canInstall && ( + <> + + void vm.handleUpload(event)} + /> + + )} ) : null} > @@ -132,7 +170,7 @@ export function PluginsPage() {
{loading ? ( @@ -143,30 +181,56 @@ export function PluginsPage() { Array.from({ length: SKELETON_CARD_COUNT }, (_, i) => ( )) - ) : payload.plugins.length === 0 ? ( -

No plugins installed yet.

+ ) : listEmpty ? ( +

+ No plugins yet — upload one, or create a site plugin in the IDE. +

) : ( - payload.plugins.map((plugin) => ( - vm.setSettingsPluginId(p.id)} - onOpenSchedules={(p) => vm.setSchedulesPluginId(p.id)} - onInstallPack={(p) => void vm.installPluginPack(p)} - onRestart={(p) => void vm.restartPlugin(p)} - onReinstall={() => fileInputRef.current?.click()} - onToggle={(p) => void vm.togglePlugin(p)} - onRemove={(p) => vm.setPendingRemove({ plugin: p, force: false })} - /> - )) + <> + {payload.plugins.map((plugin) => ( + vm.setSettingsPluginId(p.id)} + onOpenSchedules={(p) => vm.setSchedulesPluginId(p.id)} + onInstallPack={(p) => void vm.installPluginPack(p)} + onRestart={(p) => void vm.restartPlugin(p)} + onReinstall={() => fileInputRef.current?.click()} + onToggle={(p) => void vm.togglePlugin(p)} + onRemove={(p) => vm.setPendingRemove({ plugin: p, force: false })} + onOpenIde={(p) => { + const localId = localIdFromSitePluginId(p.id) + if (localId) openIde(localId) + }} + /> + ))} + {draftOnlySitePlugins.map((plugin) => ( + + ))} + )}
+ {newPluginOpen && ( + plugin.localId)} + onClose={() => setNewPluginOpen(false)} + onCreated={(localId) => { + setNewPluginOpen(false) + openIde(localId) + }} + /> + )} + {settingsPluginId && ( void +} + +export function DraftSitePluginCard({ plugin, onOpenIde }: DraftSitePluginCardProps) { + return ( +
+
+
+
+

{plugin.name}

+ + draft + + + {sitePluginStateLabel(plugin.state)} + +
+
+
+ +
+
+
+

+ {plugin.manifestError ?? `${plugin.pluginId} — authored in this site's draft, not activated yet.`} +

+
+
+ ) +} diff --git a/src/admin/pages/plugins/components/NewSitePluginDialog.module.css b/src/admin/pages/plugins/components/NewSitePluginDialog.module.css new file mode 100644 index 000000000..9b26d4316 --- /dev/null +++ b/src/admin/pages/plugins/components/NewSitePluginDialog.module.css @@ -0,0 +1,54 @@ +.body { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.label { + font-size: var(--text-xs); + color: var(--text-subtle); + margin-top: var(--space-xs); +} + +.fieldError { + margin: 0; + font-size: var(--text-xs); + color: var(--danger); +} + +.templates { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-xs); +} + +.templateCard { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3xs); + height: auto; + padding: var(--space-s); + text-align: left; + white-space: normal; + border: 1px solid var(--border-muted); + border-radius: var(--radius); +} + +.templateCard[aria-checked='true'] { + border-color: var(--overlay); + background: var(--bg-surface-3); +} + +.templateLabel { + font-size: var(--text-s); + font-weight: 600; + color: var(--text); +} + +.templateDescription { + font-size: var(--text-2xs); + line-height: 1.5; + color: var(--text-subtle); +} + diff --git a/src/admin/pages/plugins/components/NewSitePluginDialog.tsx b/src/admin/pages/plugins/components/NewSitePluginDialog.tsx new file mode 100644 index 000000000..0a645b3ee --- /dev/null +++ b/src/admin/pages/plugins/components/NewSitePluginDialog.tsx @@ -0,0 +1,163 @@ +/** + * NewSitePluginDialog — name + template picker for `New site plugin`. + * + * The kebab-case local id derives live from the name (shown, editable); + * each template scaffolds a WORKING minimal shape with its permissions + * pre-declared (declared, not granted — consent happens at activation). + * On success the caller navigates straight into the Plugin IDE. + */ +import { useState } from 'react' +import { apiRequest } from '@core/http' +import { Type } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import { + SITE_PLUGIN_LOCAL_ID_PATTERN, + SITE_PLUGIN_TEMPLATES, + sitePluginLocalIdFromName, + type SitePluginTemplateId, +} from '@core/site-plugins' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { Input } from '@ui/components/Input' +import { pushToast } from '@ui/components/Toast' +import styles from './NewSitePluginDialog.module.css' + +const ScaffoldResponseSchema = Type.Object({ + ok: Type.Boolean(), + localId: Type.String(), + files: Type.Array(Type.String()), +}) + +interface NewSitePluginDialogProps { + existingLocalIds: string[] + onClose: () => void + onCreated: (localId: string) => void +} + +export function NewSitePluginDialog({ + existingLocalIds, + onClose, + onCreated, +}: NewSitePluginDialogProps) { + const [name, setName] = useState('') + const [localIdTouched, setLocalIdTouched] = useState(false) + const [localId, setLocalId] = useState('') + const [template, setTemplate] = useState('module') + const [busy, setBusy] = useState(false) + + const effectiveLocalId = localIdTouched ? localId : sitePluginLocalIdFromName(name) + const idValid = SITE_PLUGIN_LOCAL_ID_PATTERN.test(effectiveLocalId) + const idTaken = existingLocalIds.includes(effectiveLocalId) + const canSubmit = name.trim().length > 0 && idValid && !idTaken && !busy + + // Field-local validation stays inline (the allowed exception); the scaffold + // request itself is an operation and toasts on failure. + const idHint = !effectiveLocalId + ? null + : !idValid + ? 'Id must be lowercase kebab-case and start with a letter.' + : idTaken + ? 'A site plugin with this id already exists.' + : null + + const submit = async (): Promise => { + if (!canSubmit) return + setBusy(true) + try { + const result = await apiRequest('/admin/api/cms/site-plugins', { + method: 'POST', + body: { name: name.trim(), localId: effectiveLocalId, template }, + schema: ScaffoldResponseSchema, + }) + onCreated(result.localId) + } catch (err) { + pushToast({ + kind: 'error', + title: 'Could not create the site plugin', + body: getErrorMessage(err, 'Unknown error'), + }) + setBusy(false) + } + } + + return ( + {} : onClose} + eyebrow="Site plugins" + title="New site plugin" + footer={ + <> + + + + } + > +
+ + setName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void submit() + }} + /> + + + { + setLocalIdTouched(true) + setLocalId(event.currentTarget.value) + }} + /> + {idHint && ( +

+ {idHint} +

+ )} + + Template +
+ {SITE_PLUGIN_TEMPLATES.map((option) => ( + + ))} +
+
+
+ ) +} diff --git a/src/admin/pages/plugins/components/PluginCard/PluginCard.tsx b/src/admin/pages/plugins/components/PluginCard/PluginCard.tsx index 5983c0630..cb41f4606 100644 --- a/src/admin/pages/plugins/components/PluginCard/PluginCard.tsx +++ b/src/admin/pages/plugins/components/PluginCard/PluginCard.tsx @@ -18,6 +18,7 @@ import { PowerOffIcon } from 'pixel-art-icons/icons/power-off' import { ReloadIcon } from 'pixel-art-icons/icons/reload' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' import { UploadIcon } from 'pixel-art-icons/icons/upload' +import { CodeIcon } from 'pixel-art-icons/icons/code' import type { InstalledPlugin } from '@core/plugin-sdk' import { safeUrl } from '@core/plugin-sdk' import styles from './PluginCard.module.css' @@ -60,6 +61,7 @@ interface PluginCardLoadingProps { onReinstall?: never onToggle?: never onRemove?: never + onOpenIde?: never canConfigure?: never canInstall?: never canManageLifecycle?: never @@ -90,6 +92,11 @@ interface PluginCardDataProps { onReinstall: () => void onToggle: (plugin: InstalledPlugin) => void onRemove: (plugin: InstalledPlugin) => void + /** + * Opens the Plugin IDE for a `source: 'site-local'` plugin. Rendered + * next to Enable/Disable only when the plugin is site-authored. + */ + onOpenIde?: (plugin: InstalledPlugin) => void } type PluginCardProps = PluginCardLoadingProps | PluginCardDataProps @@ -141,6 +148,7 @@ export function PluginCard(props: PluginCardProps) { onReinstall, onToggle, onRemove, + onOpenIde, } = props const status = pluginStatus(plugin) const iconSrc = @@ -248,6 +256,18 @@ export function PluginCard(props: PluginCardProps) { Restart )} + {onOpenIde && plugin.source === 'site-local' && ( + + )} {canManageLifecycle && status.status !== 'error' && ( + ) + + const renderPendingNew = (parentFolder: string, depth: number) => ( + + + setPendingNew(null)} + /> + + ) + + const renderFile = (entry: FileEntry, depth: number) => { + const filePeers = peersByFileId.get(entry.fileId) ?? [] + const isRenaming = renamingFileId === entry.fileId + return ( + + {isRenaming ? ( + <> + + submitRename(entry.fileId, value)} + onCancel={() => setRenamingFileId(null)} + /> + + ) : ( + renderRowButton(entry, false, ( + <> + + + {entry.label} + + {filePeers.length > 0 && ( + + {filePeers.slice(0, 3).map((peer) => ( + + ))} + + )} + + )) + )} + + ) + } + + const renderFolder = (entry: FolderEntry, depth: number) => { + const expanded = !collapsed.has(entry.path) + const selected = selectedFolder === entry.path + return ( + + + {renderRowButton(entry, expanded, ( + <> + + + {entry.label} + + + ))} + + {expanded && ( +
+ {renderEntries(entry.children, depth + 1)} + {pendingNew === entry.path && renderPendingNew(entry.path, depth + 1)} +
+ )} +
+ ) + } + + const renderEntries = (entries: TreeEntry[], depth: number) => + entries.map((entry) => (entry.kind === 'folder' ? renderFolder(entry, depth) : renderFile(entry, depth))) + + return ( + + {pluginName} + {activeVersion && ( + + v{sitePluginDisplayVersion(activeVersion)} + + )} + + )} + ariaLabel={`${pluginName} files`} + testId="ide-file-tree" + body="bare" + bodyClassName={styles.body} + onClose={onClose} + headerActions={( + + )} + > +
+ + {renderEntries(tree, 0)} + {pendingNew === ROOT && renderPendingNew(ROOT, 0)} + +
+ + {menu && ( + setMenu(null)} + > + {menu.entry.kind === 'folder' ? ( + { + startNewFile(menu.entry.path) + setMenu(null) + }} + > + New file… + + ) : ( + <> + { + startNewFile(parentFolderOf(menu.entry.path)) + setMenu(null) + }} + > + New file here… + + { + setRenamingFileId(menu.entry.kind === 'file' ? menu.entry.fileId : null) + setMenu(null) + }} + > + Rename… + + { + if (menu.entry.kind === 'file') onDelete(menu.entry.fileId) + setMenu(null) + }} + > + Delete file + + + )} + + )} +
+ ) +} + +interface InlineRenameInputProps { + value: string + ariaLabel: string + placeholder?: string + onCommit: (value: string) => void + onCancel: () => void +} + +/** Same behavior as the Site Explorer's inline rename: select-all on mount, + * Enter commits, Escape cancels, blur commits (empty cancels). */ +function InlineRenameInput({ + value, + ariaLabel, + placeholder, + onCommit, + onCancel, +}: InlineRenameInputProps) { + const inputRef = useRef(null) + + useEffect(() => { + requestAnimationFrame(() => inputRef.current?.select()) + }, []) + + function commit(): void { + const trimmed = inputRef.current?.value.trim() ?? '' + if (!trimmed) { + onCancel() + return + } + onCommit(trimmed) + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + commit() + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + onCancel() + } + } + + return ( + event.stopPropagation()} + /> + ) +} diff --git a/src/admin/pages/plugins/ide/IdeActions.module.css b/src/admin/pages/plugins/ide/IdeActions.module.css new file mode 100644 index 000000000..022cdee09 --- /dev/null +++ b/src/admin/pages/plugins/ide/IdeActions.module.css @@ -0,0 +1,20 @@ +.actions { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.peerStack { + display: inline-flex; + align-items: center; + padding-right: var(--space-3xs); +} + +.peerAvatar { + margin-left: calc(-1 * var(--space-3xs)); +} + +.peerAvatar:first-child { + margin-left: 0; +} + diff --git a/src/admin/pages/plugins/ide/IdeActions.tsx b/src/admin/pages/plugins/ide/IdeActions.tsx new file mode 100644 index 000000000..529ba6d92 --- /dev/null +++ b/src/admin/pages/plugins/ide/IdeActions.tsx @@ -0,0 +1,236 @@ +/** + * IdeActions — the IDE toolbar's right slot: peer avatar stack, the runtime + * state indicator, and ONE split button (the same control the site + * toolbar's Publish uses): the state-appropriate primary action on the + * left, every other action in the menu. Unavailable actions are disabled + * with an inline reason, never hidden. + */ +import { + sitePluginDisplayVersion, + sitePluginPrimaryAction, + sitePluginStateLabel, + type SitePluginRuntimeState, + type SitePluginSummary, +} from '@core/site-plugins' +import { SplitButton, type SplitButtonMenuItem } from '@ui/components/SplitButton' +import { PeerAvatar } from '@site/collab/PeerAvatar' +import { formatRelativeTime } from '@core/utils/relativeTime' +import { ToolbarStatus, type ToolbarStatusTone } from '@site/toolbar/ToolbarStatus' +import type { IdePeer } from './idePresence' +import styles from './IdeActions.module.css' + +/** The site toolbar's tone vocabulary, applied to the plugin's runtime state. */ +function stateTone(state: SitePluginRuntimeState): ToolbarStatusTone { + switch (state) { + case 'active': + return 'success' + case 'build-failed': + case 'runtime-error': + case 'source-missing': + return 'danger' + case 'draft-changed': + case 'disabled': + return 'neutral' + } +} + +const INSTALL_REASON = 'Requires the plugins.install permission' +const LIFECYCLE_REASON = 'Requires the plugins.lifecycle permission' + +interface IdeActionsProps { + summary: SitePluginSummary | null + peers: IdePeer[] + /** plugins.install — build & activate, rollback, delete. */ + canInstall: boolean + /** plugins.lifecycle — enable, deactivate, restart (server-gated + step-up). */ + canManageLifecycle: boolean + activating: boolean + onActivate: () => void + onPreview: () => void + /** Re-activate one retained build (a version from `summary.revisions`). */ + onRollback: (version: string) => void + onSetEnabled: (enabled: boolean) => void + onRestart: () => void + onRunDiagnostics: () => void + /** Logs, settings, schedules and restart-after-crash live on the Plugins page. */ + onOpenPluginsPage: () => void + onDelete: () => void +} + +export function IdeActions({ + summary, + peers, + canInstall, + canManageLifecycle, + activating, + onActivate, + onPreview, + onRollback, + onSetEnabled, + onRestart, + onRunDiagnostics, + onOpenPluginsPage, + onDelete, +}: IdeActionsProps) { + const state = summary?.state ?? null + const primary = state ? sitePluginPrimaryAction(state) : null + const uniquePeers = new Map(peers.map((peer) => [peer.user.id, peer])) + + // Same split as the server: install-class actions need plugins.install, + // enable/disable/restart need plugins.lifecycle. + const needsInstallCap = primary?.action === 'activate' || primary?.action === 'delete' + const needsLifecycleCap = primary?.action === 'enable' + // `active` has no state action of its own: the left half keeps reading + // "Build & activate" and says why there is nothing to build. + const primaryLabel = primary && primary.action !== null ? primary.label : 'Build & activate' + const primaryBlockedReason = !summary + ? 'Loading the plugin state…' + : primary?.action === null + ? 'The active revision already matches the draft' + : needsInstallCap && !canInstall + ? INSTALL_REASON + : needsLifecycleCap && !canManageLifecycle + ? LIFECYCLE_REASON + : null + + const runPrimary = (): void => { + switch (primary?.action) { + case 'activate': + // The page opens the permission review first when the grant set + // changed; the server enforces step-up either way. + onActivate() + return + case 'diagnostics': + onRunDiagnostics() + return + case 'open-plugins-page': + onOpenPluginsPage() + return + case 'delete': + onDelete() + return + case 'enable': + onSetEnabled(true) + return + case null: + case undefined: + return + } + } + + const lifecycleBlockedReason = !canManageLifecycle + ? LIFECYCLE_REASON + : !summary?.activeVersion + ? 'Nothing is activated yet' + : null + const previewBlockedReason = !summary?.hasDraftSource + ? 'The draft source is missing' + : !summary.hasModules + ? 'The draft declares no module pack' + : null + + // Every retained build is a rollback target; the active one is listed + // so the picker reads as a history, but cannot be re-activated. + const revisions = summary?.revisions ?? [] + const rollbackTargets: SplitButtonMenuItem[] = revisions.map((revision) => { + const active = revision.version === summary?.activeVersion + return { + id: `rollback:${revision.version}`, + label: `v${sitePluginDisplayVersion(revision.version)} · ${formatRelativeTime(revision.builtAt)}${active ? ' · active' : ''}`, + disabled: active, + tooltip: active ? 'This build is the active one' : undefined, + onSelect: () => onRollback(revision.version), + testId: `ide-rollback-${sitePluginDisplayVersion(revision.version)}`, + } + }) + const rollbackBlockedReason = !canInstall + ? INSTALL_REASON + : revisions.length < 2 + ? 'No other build is retained yet' + : null + + const menuItems: SplitButtonMenuItem[] = [ + { + id: 'preview', + label: 'Preview in canvas', + disabled: previewBlockedReason !== null, + tooltip: previewBlockedReason ?? undefined, + onSelect: onPreview, + testId: 'ide-preview', + }, + { id: 'diagnostics', label: 'Re-run diagnostics', onSelect: onRunDiagnostics }, + { + id: 'rollback', + label: 'Roll back to…', + separatorBefore: true, + disabled: rollbackBlockedReason !== null, + tooltip: rollbackBlockedReason ?? undefined, + children: rollbackTargets, + testId: 'ide-rollback', + }, + { + id: 'deactivate', + label: 'Deactivate', + disabled: lifecycleBlockedReason !== null || summary?.state === 'disabled', + tooltip: + lifecycleBlockedReason ?? + (summary?.state === 'disabled' ? 'Already deactivated' : undefined), + onSelect: () => onSetEnabled(false), + }, + { + id: 'restart', + label: 'Restart', + disabled: lifecycleBlockedReason !== null, + tooltip: lifecycleBlockedReason ?? undefined, + onSelect: onRestart, + }, + { id: 'plugins-page', label: 'Open on Plugins page', onSelect: onOpenPluginsPage }, + { + id: 'delete', + label: 'Delete site plugin', + separatorBefore: true, + danger: true, + disabled: !canInstall, + tooltip: !canInstall ? INSTALL_REASON : undefined, + onSelect: onDelete, + }, + ] + + return ( +
+ {uniquePeers.size > 0 && ( + + {[...uniquePeers.values()].slice(0, 4).map((peer) => ( + + ))} + + )} + + {state && ( + + )} + + +
+ ) +} diff --git a/src/admin/pages/plugins/ide/IdeFileEditor.tsx b/src/admin/pages/plugins/ide/IdeFileEditor.tsx new file mode 100644 index 000000000..e2f10c3d6 --- /dev/null +++ b/src/admin/pages/plugins/ide/IdeFileEditor.tsx @@ -0,0 +1,60 @@ +/** + * IdeFileEditor — one co-edited code buffer in the Plugin IDE. + * + * Lazy-mounts the collab CodeMirror module (CodeMirror stays out of the + * eager admin graph) bound to the file's Y.Text: keystrokes stream live + * over the site socket, peers' edits and carets render in place, undo is + * per-file and local-only. + */ +import { lazy, Suspense, useSyncExternalStore } from 'react' +import type { IdeCollabSession } from './ideCollab' +import { ideLanguageForPath } from './ideLanguage' +import styles from './SitePluginIdePage.module.css' + +const CollabCodeMirrorEditor = lazy( + () => import('@site/code-editor/CollabCodeMirrorEditor'), +) + +interface IdeFileEditorProps { + session: IdeCollabSession + fileId: string + path: string + /** + * The session's rebind generation. A relay reset replaces the Y.Text this + * buffer is bound to; keying the mount on the generation remounts the + * editor onto the fresh text instead of leaving it on the destroyed one. + */ + generation: number + readOnly: boolean +} + +export function IdeFileEditor({ session, fileId, path, generation, readOnly }: IdeFileEditorProps) { + // The live Y.Text is read through the session's sync subscription, not as + // a plain derived value: a relay reset swaps the underlying doc while + // `session` and `fileId` stay the same, so the React Compiler would keep + // handing back the memoized, destroyed text. Both snapshots are stable + // per binding (map lookups), which is what useSyncExternalStore needs. + const text = useSyncExternalStore(session.onSyncChange, () => session.contentText(fileId)) + const undoManager = useSyncExternalStore( + session.onSyncChange, + () => (text ? session.undoManagerFor(fileId) : null), + ) + if (!text) { + return
This file has no editable text content.
+ } + + return ( + Loading editor…}> +
+ +
+
+ ) +} diff --git a/src/admin/pages/plugins/ide/IdeSidebar.tsx b/src/admin/pages/plugins/ide/IdeSidebar.tsx new file mode 100644 index 000000000..d49c84cbb --- /dev/null +++ b/src/admin/pages/plugins/ide/IdeSidebar.tsx @@ -0,0 +1,151 @@ +/** + * IdeSidebar — the IDE's left sidebar, mirroring the Content workspace's + * sidebar exactly: the shared left-sidebar chrome (42px icon rail + + * absolutely-positioned panel slot + resize handle + persisted width), with + * a Files rail button for the file tree and — when the user holds ai.chat — + * an AI assistant button in the rail's global group, one panel at a time. + */ +import { useRef, type CSSProperties, type ReactNode } from 'react' +import { Button } from '@ui/components/Button' +import type { IconComponent } from 'pixel-art-icons/types' +import { FileTextSolidIcon } from 'pixel-art-icons/icons/file-text-solid' +import { AiSettingsSolidIcon } from 'pixel-art-icons/icons/ai-settings-solid' +import { railAccent, railTintVar } from '@ui/railAccent' +import { useWorkspaceLayout } from '@admin/state/workspaceLayout' +import { SidebarResizeHandle } from '@admin/shared/SidebarResizeHandle' +import leftSidebarStyles from '@site/sidebars/LeftSidebar/LeftSidebar.module.css' +import panelRailStyles from '@site/sidebars/PanelRail/PanelRail.module.css' + +export type IdePanelId = 'files' | 'agent' + +interface IdeSidebarProps { + activePanel: IdePanelId | null + onActivePanelChange: (panel: IdePanelId | null) => void + filesPanel: ReactNode + /** AI assistant panel — same docked variant Content/Site use. */ + agentPanel: ReactNode + canUseAiChat: boolean +} + +export function IdeSidebar({ + activePanel, + onActivePanelChange, + filesPanel, + agentPanel, + canUseAiChat, +}: IdeSidebarProps) { + const sidebarRef = useRef(null) + const leftSidebarWidth = useWorkspaceLayout((s) => s.leftSidebarWidth) + const setLeftSidebarWidth = useWorkspaceLayout((s) => s.setLeftSidebarWidth) + const panelWidth = activePanel ? leftSidebarWidth : 0 + const style = { + '--left-sidebar-panel-width': `${panelWidth}px`, + '--left-sidebar-panel-layout-width': `${leftSidebarWidth}px`, + } as CSSProperties + + return ( + + ) +} + +interface IdeRailButtonProps { + id: IdePanelId + label: string + icon: IconComponent + iconName: string + active: boolean + onToggle: () => void +} + +function IdeRailButton({ id, label, icon, iconName, active, onToggle }: IdeRailButtonProps) { + const RailIcon = icon + const action = active ? 'Close' : 'Open' + const accent = railAccent(`pluginIde:${id}:${label}`) + const style = { + '--rail-icon-tint': railTintVar(accent), + } as CSSProperties + + return ( + + ) +} diff --git a/src/admin/pages/plugins/ide/SitePluginIdePage.module.css b/src/admin/pages/plugins/ide/SitePluginIdePage.module.css new file mode 100644 index 000000000..499851389 --- /dev/null +++ b/src/admin/pages/plugins/ide/SitePluginIdePage.module.css @@ -0,0 +1,61 @@ +.editorColumn { + display: flex; + flex-direction: column; + /* The layout's .canvasContent is a flex row — without flex:1 the editor + column sizes to its content instead of filling the stage. */ + flex: 1; + min-width: 0; + height: 100%; + min-height: 0; + overflow: hidden; + /* The standard elevated canvas surface (same treatment as the Data + grid): the shell stays --bg-body black; the working area sits on + --bg-surface-2 with rounded top corners. */ + background: var(--bg-surface-2); + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +.editorArea { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +.editorArea :global([data-codemirror-container]) { + height: 100%; +} + +.editorMount { + height: 100%; + min-height: 0; +} + +.editorEmpty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xl); + color: var(--text-subtle); + font-size: var(--text-s); + text-align: center; +} + +.missing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-s); + height: 100%; + color: var(--text-subtle); +} + +.missingTitle { + margin: 0; + font-size: var(--text-l); + color: var(--text); +} diff --git a/src/admin/pages/plugins/ide/SitePluginIdePage.tsx b/src/admin/pages/plugins/ide/SitePluginIdePage.tsx new file mode 100644 index 000000000..9e2eaf673 --- /dev/null +++ b/src/admin/pages/plugins/ide/SitePluginIdePage.tsx @@ -0,0 +1,272 @@ +/** + * SitePluginIdePage — the full-screen Plugin IDE + * (`/admin/plugins/develop/:localId`). + * + * Renders in the same workspace canvas shell Content/Data/Media use: + * standard toolbar + section nav, a resizable left file tree, and the + * co-edited CodeMirror buffer with the diagnostics strip beneath it. There + * is no right panel — plugin.json is edited as raw JSON in the buffer, and + * the diagnostics strip names every manifest mistake. + * + * Everything here co-edits live: files are CRDT state on the site socket, + * peers' carets render inline, and there is no save button — Cmd+S re-runs + * diagnostics for muscle memory. + */ +import { useEffect, useState } from 'react' +import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout' +import { useNavigate, useParams } from '@admin/lib/routing' +import { useAuthenticatedAdminUser } from '@admin/sessionContext' +import { + canEditPlugins, + canInstallPlugins, + canManagePluginLifecycle, + canUseAiChat, +} from '@admin/access' +import { ConfirmDeleteDialog } from '@admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog' +import { SITE_PLUGIN_LOCAL_ID_PATTERN, sitePluginFolder } from '@core/site-plugins' +import { useSitePluginIde } from './useSitePluginIde' +import { useIdePeers, usePublishIdePresence } from './idePresence' +import { FileTreePane } from './FileTreePane' +import { SitePluginPermissionReviewDialog } from './SitePluginPermissionReviewDialog' +import { IdeFileEditor } from './IdeFileEditor' +import { DiagnosticsStrip } from './DiagnosticsStrip' +import { IdeActions } from './IdeActions' +import { IdeSidebar, type IdePanelId } from './IdeSidebar' +import { PluginIdeAgentMount } from './agent/PluginIdeAgentMount' +import { usePluginIdeToolBridge } from './agent/usePluginIdeToolBridge' +import styles from './SitePluginIdePage.module.css' + +export function SitePluginIdePage() { + const params = useParams<{ localId: string }>() + const rawLocalId = params.localId ?? '' + const localId = SITE_PLUGIN_LOCAL_ID_PATTERN.test(rawLocalId) ? rawLocalId : null + + if (!localId) { + return ( + +

Unknown site plugin

+

“{rawLocalId}” is not a valid site plugin id.

+ + )} + /> + ) + } + + return +} + +interface PendingDelete { + title: string + description: string + confirmLabel: string + commit: () => void +} + +function SitePluginIde({ localId }: { localId: string }) { + const currentUser = useAuthenticatedAdminUser() + const navigate = useNavigate() + const canEdit = canEditPlugins(currentUser) + const canInstall = canInstallPlugins(currentUser) + const canManageLifecycle = canManagePluginLifecycle(currentUser) + const canChat = canUseAiChat(currentUser) + const [pendingDelete, setPendingDelete] = useState(null) + const [reviewOpen, setReviewOpen] = useState(false) + const [activePanel, setActivePanel] = useState('files') + + const vm = useSitePluginIde(localId) + const { session, files, activeFileId, selectFile, runValidation } = vm + const peers = useIdePeers(session, localId) + + const activeFile = files.find((file) => file.id === activeFileId) ?? null + const folder = sitePluginFolder(localId) + + usePublishIdePresence( + session, + currentUser, + localId, + activeFile ? { fileId: activeFile.id, path: activeFile.path } : null, + ) + + // Agent + MCP tool surface — registered for the whole page mount so an + // external MCP client reaches the open IDE even with the AI panel closed. + usePluginIdeToolBridge({ + localId, + folder, + session, + files, + activeFile: activeFile ? { id: activeFile.id, path: activeFile.path } : null, + summary: vm.summary, + diagnostics: vm.diagnostics, + selectFile, + canEdit, + currentUser: { + id: currentUser?.id ?? '', + displayName: currentUser?.displayName ?? '', + email: currentUser?.email ?? '', + }, + }) + + // Auto-select plugin.json (or the first file) once files arrive. Only + // while synced: during a relay reset the file list is empty for a moment, + // and reacting to that would drop the active buffer and land the user in + // the manifest when the reseeded doc (same file ids) comes back. + useEffect(() => { + if (!vm.synced) return + if (activeFileId && files.some((file) => file.id === activeFileId)) return + const manifest = files.find((file) => file.path === `${folder}plugin.json`) + selectFile(manifest?.id ?? files[0]?.id ?? null) + }, [vm.synced, files, activeFileId, folder, selectFile]) + + // Cmd+S — there is nothing to save (edits persist live); honor the muscle + // memory by re-running diagnostics instead of the browser save dialog. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault() + runValidation() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [runValidation]) + + return ( + } + filesPanel={( + setActivePanel(null)} + localId={localId} + pluginName={vm.summary?.name ?? localId} + activeVersion={vm.summary?.activeVersion ?? null} + files={files} + activeFileId={activeFileId} + peers={peers} + canEdit={canEdit} + ready={vm.synced} + onSelect={selectFile} + onCreate={(relativePath) => { + if (!session) return + const id = session.createFile(`${folder}${relativePath}`) + selectFile(id) + }} + onRename={(fileId, relativePath) => { + session?.renameFile(fileId, `${folder}${relativePath}`) + }} + onDelete={(fileId) => { + const file = files.find((entry) => entry.id === fileId) + setPendingDelete({ + title: `Delete ${file?.path.slice(folder.length) ?? 'this file'}?`, + description: 'The file is removed from the live draft for every editor.', + confirmLabel: 'Delete file', + commit: () => { + session?.deleteFile(fileId) + if (activeFileId === fileId) selectFile(null) + }, + }) + }} + /> + )} + /> + )} + contentCanvas={( +
+
+ {!session || !vm.synced ? ( +
+ Connecting to the live draft… +
+ ) : activeFile ? ( + + ) : ( +
+ {files.length === 0 + ? `No source yet — this plugin has no files under ${folder}.` + : 'Select a file to start editing.'} +
+ )} +
+ +
+ )} + toolbarRightSlot={( + <> + { + // Grant-changing activations are consent moments — show the + // review (what will be granted/revoked) before building. + const summary = vm.summary + const grantsChange = + summary !== null && + (summary.newPermissions.length > 0 || summary.removedPermissions.length > 0) + if (grantsChange) setReviewOpen(true) + else void vm.activate() + }} + onPreview={vm.openPreview} + onRollback={(version) => void vm.rollback(version)} + onSetEnabled={(enabled) => void vm.setEnabled(enabled)} + onRestart={() => void vm.restart()} + onRunDiagnostics={vm.runValidation} + onOpenPluginsPage={() => navigate('/admin/plugins')} + onDelete={() => + setPendingDelete({ + title: `Delete site plugin “${localId}”?`, + description: + 'Removes the runtime plugin, its generated revisions, settings and secrets, AND the source folder from the draft.', + confirmLabel: 'Delete site plugin', + commit: () => void vm.deletePlugin(), + }) + } + /> + {reviewOpen && vm.summary && ( + setReviewOpen(false)} + onConfirm={() => { + void vm.activate().finally(() => setReviewOpen(false)) + }} + /> + )} + {pendingDelete && ( + setPendingDelete(null)} + onConfirm={() => { + pendingDelete.commit() + setPendingDelete(null) + }} + /> + )} + + )} + /> + ) +} diff --git a/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.module.css b/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.module.css new file mode 100644 index 000000000..bb9e644cd --- /dev/null +++ b/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.module.css @@ -0,0 +1,59 @@ +.body { + display: flex; + flex-direction: column; + gap: var(--space-s); +} + +.intro, +.removedIntro { + margin: 0; + font-size: var(--text-s); + line-height: 1.5; + color: var(--text-subtle); +} + +.list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-3xs); +} + +.row, +.rowRemoved { + display: flex; + align-items: baseline; + gap: var(--space-s); + padding: var(--space-3xs) var(--space-s); + border-radius: var(--radius-sm); + background: var(--bg-surface-3); +} + +.rowRemoved { + opacity: 0.6; + text-decoration: line-through; +} + +.permissionId { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text); +} + +.permissionLabel { + font-size: var(--text-xs); + color: var(--text-subtle); +} + +.dangerNote { + margin: 0; + padding: var(--space-xs) var(--space-s); + border-radius: var(--radius); + border-left: 2px solid var(--danger); + background: var(--bg-surface-3); + font-size: var(--text-xs); + line-height: 1.5; + color: var(--text); +} diff --git a/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.tsx b/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.tsx new file mode 100644 index 000000000..54b1f7a6d --- /dev/null +++ b/src/admin/pages/plugins/ide/SitePluginPermissionReviewDialog.tsx @@ -0,0 +1,99 @@ +/** + * SitePluginPermissionReviewDialog — the consent moment before a + * grant-changing activation. Shows exactly what `Build & activate` will + * grant (and revoke): new permissions with labels, removed permissions, + * and the dangerous-code warning when editor.code is among them. Approval + * proceeds to activation, where the server additionally enforces + * plugins.install + step-up. + */ +import { isPluginPermission, permissionLabel } from '@core/plugin-sdk' +import type { SitePluginSummary } from '@core/site-plugins' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import styles from './SitePluginPermissionReviewDialog.module.css' + +interface SitePluginPermissionReviewDialogProps { + summary: SitePluginSummary + busy: boolean + onClose: () => void + onConfirm: () => void +} + +export function SitePluginPermissionReviewDialog({ + summary, + busy, + onClose, + onConfirm, +}: SitePluginPermissionReviewDialogProps) { + const added = summary.newPermissions + const removed = summary.removedPermissions + const dangerous = added.includes('editor.code') + + return ( + {} : onClose} + tone={dangerous ? 'danger' : undefined} + eyebrow="Permission review" + title={`Activate ${summary.name}`} + footer={ + <> + + + + } + > +
+

+ Building activates this plugin’s current draft with exactly the + permissions it declares. You will be asked for your password to + confirm. +

+ + {added.length > 0 && ( +
    + {added.map((permission) => ( +
  • + {permission} + + {isPluginPermission(permission) ? permissionLabel(permission) : permission} + +
  • + ))} +
+ )} + + {removed.length > 0 && ( + <> +

No longer requested (grants shrink):

+
    + {removed.map((permission) => ( +
  • + {permission} +
  • + ))} +
+ + )} + + {dangerous && ( +

+ editor.code runs this plugin’s JavaScript unsandboxed in every + admin’s browser window. Only activate code you trust. +

+ )} +
+
+ ) +} diff --git a/src/admin/pages/plugins/ide/agent/PluginIdeAgentMount.tsx b/src/admin/pages/plugins/ide/agent/PluginIdeAgentMount.tsx new file mode 100644 index 000000000..b803a0f74 --- /dev/null +++ b/src/admin/pages/plugins/ide/agent/PluginIdeAgentMount.tsx @@ -0,0 +1,25 @@ +/** The docked Plugin IDE agent UI. Tool registration lives at page level. */ +import { useEffect, useState } from 'react' +import { AgentStoreProvider } from '@admin/ai/AgentStoreContext' +import { createScopedAgentStore } from '@admin/ai/createScopedAgentStore' +import { AgentPanel } from '@site/panels/AgentPanel' +import { pluginAgentSliceConfig } from './agentSliceConfig.plugin' + +interface PluginIdeAgentMountProps { + isVisible: boolean +} + +export function PluginIdeAgentMount({ isVisible }: PluginIdeAgentMountProps) { + const [store] = useState(() => createScopedAgentStore(pluginAgentSliceConfig)) + + useEffect(() => { + if (isVisible) store.getState().openAgent() + else store.getState().closeAgent() + }, [isVisible, store]) + + return ( + + + + ) +} diff --git a/src/admin/pages/plugins/ide/agent/agentSliceConfig.plugin.ts b/src/admin/pages/plugins/ide/agent/agentSliceConfig.plugin.ts new file mode 100644 index 000000000..6a4df3d21 --- /dev/null +++ b/src/admin/pages/plugins/ide/agent/agentSliceConfig.plugin.ts @@ -0,0 +1,23 @@ +/** + * Plugin-IDE agent-slice config — supplied to `createAgentSlice` when the + * IDE's standalone Zustand store is composed. + * + * Mirrors `agentSliceConfig.content.ts`: + * - declares `scope: 'plugin'` for URL + JSON wiring, + * - snapshots the live IDE (open plugin, files, runtime state) via the + * registered PluginIdeBridgeHandle, + * - dispatches browser tools through `executePluginTool`, + * - points the no-provider error at the "plugin" scope default. + */ +import type { AgentSliceConfig } from '@site/agent' +import { executePluginTool } from './pluginBridge' +import { emptyPluginIdeSnapshot, getPluginIdeBridgeHandle } from './pluginBridgeHandle' + +export const pluginAgentSliceConfig: AgentSliceConfig = { + scope: 'plugin', + buildSnapshot: () => + getPluginIdeBridgeHandle()?.buildSnapshot() ?? emptyPluginIdeSnapshot(), + dispatchTool: executePluginTool, + noProviderMessage: + 'No AI provider configured for the Plugin IDE. Open /admin/ai/providers to add a credential, then /admin/ai/defaults to pick one for the "plugin" scope.', +} diff --git a/src/admin/pages/plugins/ide/agent/pluginBridge.ts b/src/admin/pages/plugins/ide/agent/pluginBridge.ts new file mode 100644 index 000000000..eaf83f220 --- /dev/null +++ b/src/admin/pages/plugins/ide/agent/pluginBridge.ts @@ -0,0 +1,329 @@ +/** + * Plugin-scope browser bridge — turns a server-issued `toolRequest` into an + * operation against the live Plugin IDE via the registered + * `PluginIdeBridgeHandle`. + * + * Both the chat panel and the workspace-scoped MCP relay call + * `executePluginTool(name, input)`; each result is posted back through the + * shared tool-result endpoint. + * + * Per-tool inputs are re-validated against the SAME TypeBox schemas the + * server advertises (`@core/ai`) — defence in depth at the browser + * boundary. File mutations go through the IdeCollabSession, so agent edits + * are CRDT transactions that merge character-level with concurrent human + * typing, ride the relay's persistence, and appear to co-editors live. + * + * Tool paths are RELATIVE to the plugin folder; this module joins them + * against `plugins//` and rejects anything that escapes it. + * + * Mirrors `contentBridge.ts` / the site executor — same canonical + * `AiToolOutput` return type. + */ +import { + aiToolError, + aiToolOk, + applyExactReplacements, + hashText, + paginateText, + utf8ByteLength, + PluginDeleteFileInputSchema, + PluginListFilesInputSchema, + PluginOpenFileInputSchema, + PluginPatchFileInputSchema, + PluginReadFileInputSchema, + PluginRenameFileInputSchema, + PluginWriteFileInputSchema, + type AiToolOutput, +} from '@core/ai' +import { isSafePath, normalizePath } from '@core/files/pathValidation' +import { sitePluginFolder } from '@core/site-plugins' +import { getErrorMessage } from '@core/utils/errorMessage' +import { parseValue, type Static } from '@core/utils/typeboxHelpers' +import type { IdeCollabSession, IdeFileMeta } from '../ideCollab' +import { + getPluginIdeBridgeHandle, + type PluginIdeBridgeHandle, +} from './pluginBridgeHandle' + +const DEFAULT_READ_MAX_CHARS = 12000 + +interface BridgeCtx { + handle: PluginIdeBridgeHandle + session: IdeCollabSession + folder: string +} + +function requireCtx(): BridgeCtx | AiToolOutput { + const handle = getPluginIdeBridgeHandle() + if (!handle) { + return aiToolError('The Plugin IDE is not open — open /admin/plugins/develop/ first.') + } + const session = handle.session() + if (!session || !session.synced()) { + return aiToolError('The Plugin IDE session is still connecting — retry in a moment.') + } + return { handle, session, folder: sitePluginFolder(handle.localId) } +} + +function isBridgeCtx(value: BridgeCtx | AiToolOutput): value is BridgeCtx { + return !('ok' in value) +} + +/** Join a model-supplied relative path onto the plugin folder, safely. */ +function resolveRelativePath(ctx: BridgeCtx, relative: string): string | null { + const normalized = normalizePath(relative) + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) { + return null + } + const full = `${ctx.folder}${normalized}` + if (!isSafePath(full) || !full.startsWith(ctx.folder)) return null + return full +} + +function relativePathOf(ctx: BridgeCtx, file: IdeFileMeta): string { + return file.path.slice(ctx.folder.length) +} + +function resolveFile( + ctx: BridgeCtx, + ref: { fileId?: string; path?: string }, +): { ok: true; file: IdeFileMeta } | { ok: false; error: string } { + if (!ref.fileId && !ref.path) { + return { ok: false, error: 'Pass either fileId or path to identify the file.' } + } + const files = ctx.handle.files() + + let file: IdeFileMeta | undefined + if (ref.fileId) { + file = files.find((candidate) => candidate.id === ref.fileId) + if (!file) return { ok: false, error: `File not found: ${ref.fileId}` } + } + if (ref.path) { + const full = resolveRelativePath(ctx, ref.path) + if (!full) return { ok: false, error: `Invalid plugin file path: ${ref.path}` } + const pathFile = files.find((candidate) => candidate.path === full) + if (!pathFile) return { ok: false, error: `File not found: ${ref.path}` } + if (file && pathFile.id !== file.id) { + return { ok: false, error: `fileId ${file.id} does not match path ${ref.path}.` } + } + file = pathFile + } + return { ok: true, file: file! } +} + +/** Live text of a file, or null when the entry carries no editable text. */ +function contentOf(ctx: BridgeCtx, fileId: string): string | null { + return ctx.session.contentText(fileId)?.toString() ?? null +} + +function noTextError(ctx: BridgeCtx, file: IdeFileMeta): AiToolOutput { + return aiToolError(`${relativePathOf(ctx, file)} has no editable text content.`) +} + +async function describeFile(ctx: BridgeCtx, file: IdeFileMeta) { + const content = contentOf(ctx, file.id) ?? '' + return { + fileId: file.id, + path: relativePathOf(ctx, file), + contentChars: content.length, + bytes: utf8ByteLength(content), + hash: await hashText(content), + updatedAt: file.updatedAt, + } +} + +function isManifestFile(ctx: BridgeCtx, file: IdeFileMeta): boolean { + return file.path === `${ctx.folder}plugin.json` +} + +// --------------------------------------------------------------------------- +// Per-tool runners +// --------------------------------------------------------------------------- + +async function runListFiles(ctx: BridgeCtx): Promise { + const files = await Promise.all(ctx.handle.files().map((file) => describeFile(ctx, file))) + return aiToolOk({ files }) +} + +async function runReadFile( + ctx: BridgeCtx, + input: Static, +): Promise { + const resolved = resolveFile(ctx, input) + if (!resolved.ok) return aiToolError(resolved.error) + + const content = contentOf(ctx, resolved.file.id) + if (content === null) return noTextError(ctx, resolved.file) + const page = paginateText(content, { + part: input.part, + maxChars: input.maxChars, + defaultMaxChars: DEFAULT_READ_MAX_CHARS, + }) + if (!page.ok) return aiToolError(page.error) + return aiToolOk({ + fileId: resolved.file.id, + path: relativePathOf(ctx, resolved.file), + content: page.content, + hash: await hashText(content), + pageInfo: page.pageInfo, + }) +} + +async function runWriteFile( + ctx: BridgeCtx, + input: Static, +): Promise { + const full = resolveRelativePath(ctx, input.path) + if (!full) return aiToolError(`Invalid plugin file path: ${input.path}`) + + const existing = ctx.handle.files().find((file) => file.path === full) + if (existing) { + ctx.session.replaceFileContent(existing.id, input.content) + return aiToolOk({ ...(await describeFile(ctx, existing)), created: false }) + } + + let fileId: string + try { + fileId = ctx.session.createFile(full, input.content) + } catch (err) { + return aiToolError(getErrorMessage(err, `Could not create ${input.path}`)) + } + const created = ctx.handle.files().find((file) => file.id === fileId) + if (!created) return aiToolError(`Create failed for ${input.path}`) + return aiToolOk({ ...(await describeFile(ctx, created)), created: true }) +} + +async function runPatchFile( + ctx: BridgeCtx, + input: Static, +): Promise { + const resolved = resolveFile(ctx, input) + if (!resolved.ok) return aiToolError(resolved.error) + + const currentContent = contentOf(ctx, resolved.file.id) + if (currentContent === null) return noTextError(ctx, resolved.file) + const currentHash = await hashText(currentContent) + if (currentHash !== input.expectedHash) { + return aiToolError( + `Hash mismatch for ${relativePathOf(ctx, resolved.file)} — the file changed since you read it. Call plugin_read_file again before patching.`, + ) + } + + const applied = applyExactReplacements(currentContent, input.replacements) + if (!applied.ok) { + const path = relativePathOf(ctx, resolved.file) + return aiToolError( + applied.reason === 'not-found' + ? `Replacement text not found in ${path}.` + : `Replacement in ${path} is ambiguous: ${applied.matches} matches. ` + + 'Use a larger oldText span or set replaceAll:true.', + ) + } + + // The hash check awaited a digest; a remote keystroke that landed in that + // window would be reverted by a diff computed from the pre-digest text. + if (contentOf(ctx, resolved.file.id) !== currentContent) { + return aiToolError( + `${relativePathOf(ctx, resolved.file)} changed while patching. Call plugin_read_file again before patching.`, + ) + } + + ctx.session.replaceFileContent(resolved.file.id, applied.content) + return aiToolOk({ + ...(await describeFile(ctx, resolved.file)), + replacements: applied.replaced, + }) +} + +async function runRenameFile( + ctx: BridgeCtx, + input: Static, +): Promise { + const resolved = resolveFile(ctx, input) + if (!resolved.ok) return aiToolError(resolved.error) + if (isManifestFile(ctx, resolved.file)) { + return aiToolError('plugin.json cannot be renamed — the build requires the manifest at the plugin root.') + } + const full = resolveRelativePath(ctx, input.newPath) + if (!full) return aiToolError(`Invalid plugin file path: ${input.newPath}`) + try { + ctx.session.renameFile(resolved.file.id, full) + } catch (err) { + return aiToolError(getErrorMessage(err, `Could not rename to ${input.newPath}`)) + } + return aiToolOk({ fileId: resolved.file.id, path: input.newPath }) +} + +function runDeleteFile( + ctx: BridgeCtx, + input: Static, +): AiToolOutput { + const resolved = resolveFile(ctx, input) + if (!resolved.ok) return aiToolError(resolved.error) + if (isManifestFile(ctx, resolved.file)) { + return aiToolError('plugin.json cannot be deleted — every plugin needs its manifest.') + } + ctx.session.deleteFile(resolved.file.id) + return aiToolOk({ deleted: true, fileId: resolved.file.id }) +} + +function runOpenFile( + ctx: BridgeCtx, + input: Static, +): AiToolOutput { + const resolved = resolveFile(ctx, input) + if (!resolved.ok) return aiToolError(resolved.error) + ctx.handle.selectFile(resolved.file.id) + return aiToolOk({ opened: relativePathOf(ctx, resolved.file) }) +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +const WRITE_TOOLS = new Set([ + 'plugin_write_file', + 'plugin_patch_file', + 'plugin_rename_file', + 'plugin_delete_file', +]) + +export async function executePluginTool( + toolName: string, + input: unknown, +): Promise { + const ctxOrError = requireCtx() + if (!isBridgeCtx(ctxOrError)) return ctxOrError + const ctx = ctxOrError + + // Capability re-check at the boundary: the server never offers write + // tools to callers without plugins.edit, but the bridge validates its own + // side too (defence in depth — mirrors the relay's update guard). + if (WRITE_TOOLS.has(toolName) && !ctx.handle.canEdit()) { + return aiToolError('You do not have the plugins.edit capability.') + } + + try { + switch (toolName) { + case 'plugin_list_files': + parseValue(PluginListFilesInputSchema, input) + return await runListFiles(ctx) + case 'plugin_read_file': + return await runReadFile(ctx, parseValue(PluginReadFileInputSchema, input)) + case 'plugin_write_file': + return await runWriteFile(ctx, parseValue(PluginWriteFileInputSchema, input)) + case 'plugin_patch_file': + return await runPatchFile(ctx, parseValue(PluginPatchFileInputSchema, input)) + case 'plugin_rename_file': + return await runRenameFile(ctx, parseValue(PluginRenameFileInputSchema, input)) + case 'plugin_delete_file': + return runDeleteFile(ctx, parseValue(PluginDeleteFileInputSchema, input)) + case 'plugin_open_file': + return runOpenFile(ctx, parseValue(PluginOpenFileInputSchema, input)) + default: + return aiToolError(`Unknown plugin tool: ${toolName}`) + } + } catch (err) { + return aiToolError(getErrorMessage(err, `Tool ${toolName} failed.`)) + } +} diff --git a/src/admin/pages/plugins/ide/agent/pluginBridgeHandle.ts b/src/admin/pages/plugins/ide/agent/pluginBridgeHandle.ts new file mode 100644 index 000000000..1ea14fd08 --- /dev/null +++ b/src/admin/pages/plugins/ide/agent/pluginBridgeHandle.ts @@ -0,0 +1,66 @@ +/** + * Plugin IDE bridge handle registry. + * + * The chat panel and the MCP relay run outside the IDE page's React tree — + * the bridge dispatcher needs an imperative entry point onto the live IDE + * state (the collab session, the file list, the runtime summary, the + * visible buffer). SitePluginIdePage registers a handle here on mount; the + * dispatcher reads it and calls methods on it. Same module-level-handle + * pattern as the content workspace's `contentBridgeHandle.ts`. + * + * The snapshot shape is `PluginIdeSnapshot` from `@core/ai` — the same + * schema the server validates against, so the two sides cannot drift. + */ +import type { PluginIdeSnapshot } from '@core/ai' +import type { SitePluginSummary } from '@core/site-plugins' +import type { IdeCollabSession, IdeFileMeta } from '../ideCollab' + +export type { PluginIdeSnapshot } from '@core/ai' +export { emptyPluginIdeSnapshot } from '@core/ai' + +export type PluginIdeAgentCurrentUser = PluginIdeSnapshot['currentUser'] + +/** + * Imperative surface the IDE page exposes to the agent bridge. Accessors + * (not fields) so every call reads the LIVE page state — the handle is + * registered once per mount and never goes stale. + */ +export interface PluginIdeBridgeHandle { + /** The plugin this IDE mount edits. */ + readonly localId: string + /** Per-request snapshot for the system prompt + server tools. */ + buildSnapshot(): PluginIdeSnapshot + /** The live collab session; null for at most the mount render. */ + session(): IdeCollabSession | null + /** Live file metas (full `plugins//…` paths), path-sorted. */ + files(): IdeFileMeta[] + /** Switch the visible buffer (plugin_open_file). */ + selectFile(fileId: string): void + /** Whether the current user may author plugin source (plugins.edit). */ + canEdit(): boolean +} + +let handle: PluginIdeBridgeHandle | null = null + +export function setPluginIdeBridgeHandle(next: PluginIdeBridgeHandle | null): void { + handle = next +} + +export function getPluginIdeBridgeHandle(): PluginIdeBridgeHandle | null { + return handle +} + +/** Narrow a SitePluginSummary into the snapshot's runtime fields. */ +export function summarySnapshotFields( + summary: SitePluginSummary | null, +): Pick< + PluginIdeSnapshot, + 'state' | 'activeVersion' | 'declaredPermissions' | 'grantedPermissions' +> { + return { + state: summary?.state ?? 'draft-changed', + activeVersion: summary?.activeVersion ?? null, + declaredPermissions: [...(summary?.declaredPermissions ?? [])], + grantedPermissions: [...(summary?.grantedPermissions ?? [])], + } +} diff --git a/src/admin/pages/plugins/ide/agent/usePluginIdeToolBridge.ts b/src/admin/pages/plugins/ide/agent/usePluginIdeToolBridge.ts new file mode 100644 index 000000000..327c77fe0 --- /dev/null +++ b/src/admin/pages/plugins/ide/agent/usePluginIdeToolBridge.ts @@ -0,0 +1,87 @@ +/** + * Register the Plugin IDE's imperative tool surface and keep its MCP relay + * connected for the entire SitePluginIdePage mount. Deliberately outside + * PluginIdeAgentMount: opening or closing the AI panel must not decide + * whether an external MCP client can reach the already-open IDE — same + * policy as `useContentToolBridge`. + */ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { sitePluginIdFromLocalId, type SitePluginSummary } from '@core/site-plugins' +import { useMcpWorkspaceBridge } from '@admin/ai/useMcpWorkspaceBridge' +import type { IdeCollabSession, IdeFileMeta } from '../ideCollab' +import { executePluginTool } from './pluginBridge' +import { + setPluginIdeBridgeHandle, + summarySnapshotFields, + type PluginIdeAgentCurrentUser, + type PluginIdeBridgeHandle, + type PluginIdeSnapshot, +} from './pluginBridgeHandle' + +interface UsePluginIdeToolBridgeOptions { + localId: string + folder: string + session: IdeCollabSession | null + files: IdeFileMeta[] + activeFile: { id: string; path: string } | null + summary: SitePluginSummary | null + /** null until the first validation run — distinct from "clean". */ + diagnostics: string[] | null + selectFile: (fileId: string | null) => void + canEdit: boolean + currentUser: PluginIdeAgentCurrentUser +} + +export function usePluginIdeToolBridge(options: UsePluginIdeToolBridgeOptions): void { + const optionsRef = useRef(options) + useLayoutEffect(() => { + optionsRef.current = options + }) + + const { localId } = options + useEffect(() => { + // File metas come from the LIVE session (the Y doc), never from React + // state: back-to-back tool calls mutate and re-read without giving React + // a render turn, so the state array lags one commit behind — a + // create-then-describe would miss its own file. Same class of problem + // the content bridge solves with flushSync; reading the CRDT directly is + // the simpler correct answer here. + const liveFiles = () => { + const current = optionsRef.current + return current.session?.pluginFiles() ?? current.files + } + const handle: PluginIdeBridgeHandle = { + localId, + buildSnapshot(): PluginIdeSnapshot { + const current = optionsRef.current + return { + localId, + pluginId: sitePluginIdFromLocalId(localId), + files: liveFiles().map((file) => ({ + id: file.id, + path: file.path.slice(current.folder.length), + })), + activeFile: current.activeFile + ? { + id: current.activeFile.id, + path: current.activeFile.path.slice(current.folder.length), + } + : null, + ...summarySnapshotFields(current.summary), + latestDiagnostics: current.diagnostics, + currentUser: current.currentUser, + } + }, + session: () => optionsRef.current.session, + files: liveFiles, + selectFile: (fileId) => optionsRef.current.selectFile(fileId), + canEdit: () => optionsRef.current.canEdit, + } + setPluginIdeBridgeHandle(handle) + return () => { + setPluginIdeBridgeHandle(null) + } + }, [localId]) + + useMcpWorkspaceBridge('plugin', executePluginTool) +} diff --git a/src/admin/pages/plugins/ide/ideCollab.ts b/src/admin/pages/plugins/ide/ideCollab.ts new file mode 100644 index 000000000..0d174fbb0 --- /dev/null +++ b/src/admin/pages/plugins/ide/ideCollab.ts @@ -0,0 +1,325 @@ +/** + * Plugin IDE collab session — the IDE's own bridge onto the site collab + * socket. Binds ONLY the site doc (`site:default`): plugin source files + * live in the shell's granular `files` Y.Map (entry per file id, `content` + * as Y.Text), so the IDE co-edits with the same machinery as the site + * editor — server-seeded docs, character-level merges, presence over the + * shared awareness — without loading pages, trees, or the editor store. + * + * File CRUD happens as direct Y transactions (IDE_ORIGIN); the relay + * persists on its debounce and the site editor's projection picks changes + * up live. Per-file Y.UndoManagers give each buffer its own local-only + * undo (the yCollab binding registers its origin on them). + * + * Two lifecycle rules keep the session honest: + * - Nothing writes before the server's initial sync. An unsynced doc has + * no `files` map yet; creating one client-side would win the merge + * (the server seeds with client id 1) and replace EVERY site file with + * the one just created. Every mutating method throws + * `IdeNotSyncedError` until `synced()` is true. + * - A relay reset (`FRAME_RESET` — an out-of-relay shell write such as a + * scaffold, a delete, a settings save, or an import reseeded the doc) + * destroys the bound Y.Doc. The session rebinds immediately, bumps its + * `generation`, and drops the undo managers that referenced the dead + * Y.Text; editors key their mounts on the generation so the buffer + * remounts onto the fresh text instead of typing into a destroyed one. + */ +import * as Y from 'yjs' +import { + applyTextDiff, + buildSiteFileEntry, + buildSiteFilesMap, + shellMap, + siteFileContentText, + MAIN_SITE_DOC_ID, +} from '@core/collab' +import { isSafePath, normalizePath } from '@core/files/pathValidation' +import type { SiteFile } from '@core/files/schemas' +import { sitePluginFolder } from '@core/site-plugins' +import { nanoid } from 'nanoid' +import { + createCollabProvider, + type BoundCollabDoc, + type CollabProvider, +} from '@site/collab/collabProvider' + +/** Origin for IDE-local transactions — streamed by the provider (≠ remote). */ +export const IDE_ORIGIN = Symbol('plugin-ide-local') + +/** Thrown by every mutating session method before the initial sync lands. */ +export class IdeNotSyncedError extends Error { + constructor() { + super('The live draft is still connecting — try again in a moment.') + this.name = 'IdeNotSyncedError' + } +} + +export interface IdeFileMeta { + id: string + path: string + updatedAt: number +} + +export interface IdeCollabSession { + provider: CollabProvider + /** + * True once the server's initial sync landed for the CURRENT binding — + * false again during a relay reset until the reseeded doc arrives. + */ + synced(): boolean + /** Fires on every sync transition, including the rebind after a reset. */ + onSyncChange(listener: () => void): () => void + /** + * Increments on every rebind. Editors key their mounts on it so a reset + * remounts the buffer onto the fresh Y.Text. + */ + generation(): number + /** Metadata of this plugin's files, path-sorted. Content stays in Y.Text. */ + pluginFiles(): IdeFileMeta[] + onFilesChange(listener: () => void): () => void + contentText(fileId: string): Y.Text | null + /** Per-file undo manager (created on demand; local origins only). */ + undoManagerFor(fileId: string): Y.UndoManager | null + createFile(path: string, content?: string): string + renameFile(fileId: string, nextPath: string): void + deleteFile(fileId: string): void + /** + * Whole-value content replace as a minimal Y.Text splice (AI agent write + * path) — concurrent remote edits outside the changed span survive. + */ + replaceFileContent(fileId: string, content: string): void + destroy(): void +} + +function projectFileMeta(id: string, entry: Y.Map): IdeFileMeta { + const path = entry.get('path') + const updatedAt = entry.get('updatedAt') + return { + id, + path: typeof path === 'string' ? path : '', + updatedAt: typeof updatedAt === 'number' ? updatedAt : 0, + } +} + +function notify(listeners: ReadonlySet<() => void>): void { + for (const listener of listeners) listener() +} + +export function createIdeCollabSession(localId: string): IdeCollabSession { + const provider = createCollabProvider() + const folder = sitePluginFolder(localId) + const filesListeners = new Set<() => void>() + const syncListeners = new Set<() => void>() + const undoManagers = new Map() + let binding: BoundCollabDoc + let generation = 0 + let destroyed = false + let filesCache: { signature: string; files: IdeFileMeta[] } = { signature: '', files: [] } + + const shell = (): Y.Map => shellMap(binding.doc) + + // Observe the whole shell deeply — file metadata changes (path renames, + // membership) re-notify; pure content keystrokes also fire but the hook + // layer collapses them via a metadata signature, so React work stays + // proportional to structural changes. + const shellObserver = (): void => notify(filesListeners) + + const attach = (): void => { + binding = provider.bind(MAIN_SITE_DOC_ID) + shell().observeDeep(shellObserver) + const bound = binding + void bound.whenSynced.then(() => { + // A binding unbound by a reset resolves its promise too — only the + // current binding's sync is news. + if (destroyed || binding !== bound) return + notify(syncListeners) + notify(filesListeners) + }) + } + attach() + + const detachReset = provider.onReset((docId) => { + if (docId !== MAIN_SITE_DOC_ID || destroyed) return + // The provider already destroyed the old doc; the undo managers hold + // its Y.Text instances and must not outlive it. + for (const manager of undoManagers.values()) manager.destroy() + undoManagers.clear() + generation += 1 + attach() + notify(syncListeners) + notify(filesListeners) + }) + + const filesMap = (): Y.Map | null => { + const value = shell().get('files') + return value instanceof Y.Map ? value : null + } + + /** + * The granular files map, upgrading a legacy LWW-array layout in place — + * from the EXISTING entries, never an empty map (an empty replacement + * would project a shell that lost every other site file). Must run + * inside a doc transaction, after sync. + */ + const ensureFilesMap = (): Y.Map => { + const current = shell() + const value = current.get('files') + if (value instanceof Y.Map) return value + if (Array.isArray(value)) { + const map = buildSiteFilesMap(value as SiteFile[]) + current.set('files', map) + return map + } + // Absent after sync means the server seed is broken. Never paper over + // it with an empty map — that map would win the merge and erase every + // other site file. + throw new Error('The live draft has no files map — refusing to write') + } + + const assertWritable = (): void => { + if (destroyed) throw new Error('The Plugin IDE session is closed') + if (!binding.synced) throw new IdeNotSyncedError() + } + + /** id/path metas across BOTH layouts (granular map, legacy LWW array). */ + const allFileMetas = (): IdeFileMeta[] => { + const map = filesMap() + if (map) { + const out: IdeFileMeta[] = [] + for (const [id, entry] of map.entries()) { + if (entry instanceof Y.Map) out.push(projectFileMeta(id, entry)) + } + return out + } + const value = shell().get('files') + if (!Array.isArray(value)) return [] + return (value as SiteFile[]).map((file) => ({ + id: file.id, + path: file.path, + updatedAt: file.updatedAt, + })) + } + + const pathTaken = (path: string, exceptFileId?: string): boolean => + allFileMetas().some((file) => file.id !== exceptFileId && file.path === path) + + const requireSafePluginPath = (rawPath: string): string => { + const normalized = normalizePath(rawPath) + if (!normalized || !isSafePath(normalized)) { + throw new Error(`Invalid path: "${rawPath}"`) + } + if (!normalized.startsWith(folder)) { + throw new Error(`Site plugin files must stay under ${folder}`) + } + return normalized + } + + return { + provider, + synced: () => binding.synced, + onSyncChange: (listener) => { + syncListeners.add(listener) + return () => { + syncListeners.delete(listener) + } + }, + generation: () => generation, + + pluginFiles: () => { + const next = allFileMetas() + .filter((file) => file.path.startsWith(folder)) + .sort((a, b) => a.path.localeCompare(b.path)) + // Same array back while ids and paths are unchanged, so a keystroke + // (which fires the same observer) is not a structural change to + // whoever renders the list. + const signature = next.map((file) => `${file.id}:${file.path}`).join('|') + if (signature !== filesCache.signature) filesCache = { signature, files: next } + return filesCache.files + }, + + onFilesChange: (listener) => { + filesListeners.add(listener) + return () => { + filesListeners.delete(listener) + } + }, + + contentText: (fileId) => siteFileContentText(shell(), fileId), + + undoManagerFor: (fileId) => { + const existing = undoManagers.get(fileId) + if (existing) return existing + const text = siteFileContentText(shell(), fileId) + if (!text) return null + // Tracks IDE transactions + whatever origins the yCollab binding + // registers on it; remote peers' edits are never undone locally. + const manager = new Y.UndoManager(text, { + trackedOrigins: new Set([IDE_ORIGIN]), + captureTimeout: 500, + }) + undoManagers.set(fileId, manager) + return manager + }, + + createFile: (rawPath, content = '') => { + assertWritable() + const path = requireSafePluginPath(rawPath) + if (pathTaken(path)) throw new Error(`A file at "${path}" already exists`) + const id = nanoid() + const now = Date.now() + const file: SiteFile = { + id, + path, + type: 'plugin', + content, + createdAt: now, + updatedAt: now, + } + binding.doc.transact(() => { + ensureFilesMap().set(id, buildSiteFileEntry(file)) + }, IDE_ORIGIN) + return id + }, + + renameFile: (fileId, nextPath) => { + assertWritable() + const path = requireSafePluginPath(nextPath) + if (pathTaken(path, fileId)) throw new Error(`A file at "${path}" already exists`) + binding.doc.transact(() => { + const entry = ensureFilesMap().get(fileId) + if (!(entry instanceof Y.Map)) return + entry.set('path', path) + entry.set('updatedAt', Date.now()) + }, IDE_ORIGIN) + }, + + deleteFile: (fileId) => { + assertWritable() + undoManagers.get(fileId)?.destroy() + undoManagers.delete(fileId) + binding.doc.transact(() => { + ensureFilesMap().delete(fileId) + }, IDE_ORIGIN) + }, + + replaceFileContent: (fileId, content) => { + assertWritable() + binding.doc.transact(() => { + const entry = ensureFilesMap().get(fileId) + if (!(entry instanceof Y.Map)) return + const text = entry.get('content') + if (!(text instanceof Y.Text)) return + applyTextDiff(text, text.toString(), content) + entry.set('updatedAt', Date.now()) + }, IDE_ORIGIN) + }, + + destroy: () => { + destroyed = true + detachReset() + for (const manager of undoManagers.values()) manager.destroy() + undoManagers.clear() + provider.destroy() + }, + } +} diff --git a/src/admin/pages/plugins/ide/ideLanguage.ts b/src/admin/pages/plugins/ide/ideLanguage.ts new file mode 100644 index 000000000..1b6a79a5d --- /dev/null +++ b/src/admin/pages/plugins/ide/ideLanguage.ts @@ -0,0 +1,15 @@ +/** + * File-extension → CodeMirror language mapping for the Plugin IDE. Lives in + * its own module (not next to a component) so Fast Refresh stays intact. + */ +import type { CodeLanguage } from '@site/code-editor/CodeMirrorEditor' + +export function ideLanguageForPath(path: string): CodeLanguage { + if (path.endsWith('.tsx') || path.endsWith('.jsx')) return 'tsx' + if (path.endsWith('.ts') || path.endsWith('.js') || path.endsWith('.mjs')) return 'ts' + if (path.endsWith('.css')) return 'css' + if (path.endsWith('.json')) return 'json' + if (path.endsWith('.md')) return 'markdown' + if (path.endsWith('.html') || path.endsWith('.svg')) return 'html' + return 'text' +} diff --git a/src/admin/pages/plugins/ide/idePresence.ts b/src/admin/pages/plugins/ide/idePresence.ts new file mode 100644 index 000000000..30b9cc0cb --- /dev/null +++ b/src/admin/pages/plugins/ide/idePresence.ts @@ -0,0 +1,130 @@ +/** + * Plugin IDE presence — publishes this IDE session's identity + active file + * into the shared site-socket awareness, and reads peers back (validated — + * peer states are wire data). + * + * The published state is `EditorPresence`-compatible (identity block checked + * server-side against the session), so site editors count IDE users in + * their toolbar roster; the extra `ideFile` field is what IDE peers key on. + * Character-precise remote carets inside a code buffer ride y-codemirror's + * own `cursor` awareness field — this module never touches it. + * + * Identity is set once per session; the active file is a FIELD update. A + * whole-state replace on every file switch would drop and re-add this + * client in every peer's roster (a visible leave + rejoin). + */ +import { useEffect, useState } from 'react' +import { safeParseValue, Type, type Static } from '@core/utils/typeboxHelpers' +import { peerColor } from '@site/collab/awarenessState' +import type { CmsCurrentUser } from '@core/persistence' +import type { IdeCollabSession } from './ideCollab' + +const IdePresenceSchema = Type.Object({ + user: Type.Object({ + id: Type.String(), + name: Type.String(), + color: Type.String(), + avatarUrl: Type.Union([Type.String(), Type.Null()]), + gravatarHash: Type.Union([Type.String(), Type.Null()]), + }), + ideFile: Type.Union([ + Type.Object({ + localId: Type.String(), + fileId: Type.String(), + path: Type.String(), + }), + Type.Null(), + ]), +}) + +export type IdePresence = Static + +export interface IdePeer extends IdePresence { + clientId: number +} + +/** Publish identity + active-file presence for this IDE session. */ +export function usePublishIdePresence( + session: IdeCollabSession | null, + user: CmsCurrentUser, + localId: string, + activeFile: { fileId: string; path: string } | null, +): void { + useEffect(() => { + if (!session) return undefined + const awareness = session.provider.awareness + const previous: Record | null = awareness.getLocalState() + awareness.setLocalState({ + ...previous, + user: { + id: user.id, + name: user.displayName, + color: peerColor(user.id), + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + }, + // EditorPresence-compatible fields so site-editor peers parse us. + docId: null, + selectedNodeIds: [], + editingNodeId: null, + pointer: null, + textCaret: null, + // Keep whatever file the field effect below already published. + ideFile: previous?.['ideFile'] ?? null, + }) + return () => { + awareness.setLocalState(null) + } + }, [session, user.id, user.displayName, user.avatarUrl, user.gravatarHash]) + + const fileId = activeFile?.fileId ?? null + const path = activeFile?.path ?? null + useEffect(() => { + if (!session) return + session.provider.awareness.setLocalStateField( + 'ideFile', + fileId && path ? { localId, fileId, path } : null, + ) + }, [session, localId, fileId, path]) +} + +function readIdePeers(session: IdeCollabSession, localId: string): IdePeer[] { + const awareness = session.provider.awareness + const peers: IdePeer[] = [] + for (const [clientId, raw] of awareness.getStates()) { + if (clientId === awareness.clientID) continue + const result = safeParseValue(IdePresenceSchema, raw) + if (!result.ok) continue + if (!result.value.ideFile || result.value.ideFile.localId !== localId) continue + peers.push({ clientId, ...result.value }) + } + return peers +} + +function peersKey(peers: IdePeer[]): string { + return peers + .map((p) => `${p.clientId}:${p.user.id}:${p.ideFile?.fileId ?? ''}`) + .sort() + .join('|') +} + +/** Peers currently inside THIS plugin's IDE (deduped per client). */ +export function useIdePeers(session: IdeCollabSession | null, localId: string): IdePeer[] { + const [peers, setPeers] = useState([]) + + useEffect(() => { + if (!session) return undefined + const awareness = session.provider.awareness + const recompute = (): void => { + const next = readIdePeers(session, localId) + setPeers((current) => (peersKey(current) === peersKey(next) ? current : next)) + } + awareness.on('change', recompute) + recompute() + return () => { + awareness.off('change', recompute) + } + }, [session, localId]) + + return peers +} diff --git a/src/admin/pages/plugins/ide/useSitePluginIde.ts b/src/admin/pages/plugins/ide/useSitePluginIde.ts new file mode 100644 index 000000000..389279f4c --- /dev/null +++ b/src/admin/pages/plugins/ide/useSitePluginIde.ts @@ -0,0 +1,289 @@ +/** + * useSitePluginIde — the Plugin IDE's view-model hook. + * + * Owns one `IdeCollabSession` for the page's lifetime (live co-edited files + * over the site socket), the plugin's summary/state from the list endpoint, + * debounced auto-validation after edits, and the lifecycle actions + * (activate / rollback / deactivate / delete / preview) with the same + * step-up retry pattern the Plugins page uses. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { apiRequest } from '@core/http' +import { Type } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import { + SitePluginsPayloadSchema, + sitePluginDisplayVersion, + type SitePluginSummary, +} from '@core/site-plugins' +import { pushToast } from '@ui/components/Toast' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { useAsyncResource } from '@admin/lib/useAsyncResource' +import { useNavigate } from '@admin/lib/routing' +import { notifyCmsPluginsChanged } from '@plugins/utils/pluginEvents' +import { createIdeCollabSession, type IdeCollabSession, type IdeFileMeta } from './ideCollab' + +const ValidateResponseSchema = Type.Object({ + ok: Type.Boolean(), + diagnostics: Type.Array(Type.String()), +}) + +/** Every lifecycle route may carry a non-fatal warning (a failed republish). */ +const LifecycleResponseSchema = Type.Object({ + warning: Type.Optional(Type.String()), +}) + +const AUTO_VALIDATE_DEBOUNCE_MS = 900 +const NO_FILES: IdeFileMeta[] = [] +const noop = (): void => {} + +interface SitePluginIdeVm { + /** Null for at most one render — the mount effect creates it. */ + session: IdeCollabSession | null + files: IdeFileMeta[] + synced: boolean + /** Bumps on every relay reset — the editor buffer remounts on it. */ + generation: number + activeFileId: string | null + selectFile: (fileId: string | null) => void + summary: SitePluginSummary | null + diagnostics: string[] + validating: boolean + runValidation: () => void + activating: boolean + activate: () => Promise + /** Re-activate one of the retained builds (`summary.revisions`). */ + rollback: (version: string) => Promise + setEnabled: (enabled: boolean) => Promise + restart: () => Promise + deletePlugin: () => Promise + openPreview: () => void +} + +export function useSitePluginIde(localId: string): SitePluginIdeVm { + const { runStepUp } = useStepUp() + const navigate = useNavigate() + + // One session per (mounted page, localId) — StrictMode-safe via ref-count + // free create/destroy in the effect below. + const [session, setSession] = useState(null) + const [activeFileId, setActiveFileId] = useState(null) + const [diagnostics, setDiagnostics] = useState([]) + const [validating, setValidating] = useState(false) + const [activating, setActivating] = useState(false) + const validateTimer = useRef | null>(null) + const validateSeq = useRef(0) + + useEffect(() => { + const created = createIdeCollabSession(localId) + let alive = true + // Deferred so the effect body never sets state synchronously (lint rule + // react-hooks/set-state-in-effect) — one tick is invisible next to the + // socket round-trip anyway. + const publish = setTimeout(() => { + if (!alive) return + setSession(created) + setActiveFileId(null) + }, 0) + return () => { + alive = false + clearTimeout(publish) + created.destroy() + } + }, [localId]) + + // Sync state and the rebind generation come straight from the session — + // both flip on a relay reset (synced → false → true, generation + 1), and + // the editor buffer remounts on the generation. + // useCallback kept: useSyncExternalStore resubscribes on identity change. + const subscribeSync = useCallback( + (onStoreChange: () => void) => session?.onSyncChange(onStoreChange) ?? noop, + [session], + ) + const synced = useSyncExternalStore(subscribeSync, () => session?.synced() ?? false) + const generation = useSyncExternalStore(subscribeSync, () => session?.generation() ?? 0) + + // File metadata. The session hands back the same array until an id or a + // path changes, so content keystrokes (which fire the same observer) + // never re-render the tree. + // useCallback kept: same reason as subscribeSync. + const subscribeFiles = useCallback( + (onStoreChange: () => void) => session?.onFilesChange(onStoreChange) ?? noop, + [session], + ) + const files = useSyncExternalStore(subscribeFiles, () => session?.pluginFiles() ?? NO_FILES) + + // The runtime summary (state chip, grants, active version): one GET, the + // canonical single-resource shape. + const summaryResource = useAsyncResource( + (signal) => + apiRequest('/admin/api/cms/site-plugins', { + schema: SitePluginsPayloadSchema, + signal, + }).then( + (payload) => payload.sitePlugins.find((entry) => entry.localId === localId) ?? null, + ), + [localId], + { fallbackError: 'Could not load the plugin state' }, + ) + const summary = summaryResource.data + const refreshSummary = summaryResource.refresh + useEffect(() => { + if (summaryResource.error) { + console.error('[SitePluginIde] summary load failed:', summaryResource.error) + } + }, [summaryResource.error]) + + // useCallback kept: runValidation is a dependency of the effects below. + const runValidation = useCallback((): void => { + const seq = ++validateSeq.current + setValidating(true) + void apiRequest(`/admin/api/cms/site-plugins/${localId}/validate`, { + method: 'POST', + schema: ValidateResponseSchema, + }) + .then((result) => { + if (seq !== validateSeq.current) return + setDiagnostics(result.diagnostics) + }) + .catch((err: unknown) => { + if (seq !== validateSeq.current) return + setDiagnostics([getErrorMessage(err, 'Validation failed')]) + }) + .finally(() => { + if (seq === validateSeq.current) setValidating(false) + }) + // State chips (draft-changed vs active) key off the content hash — keep + // the summary fresh alongside diagnostics. + refreshSummary() + }, [localId, refreshSummary]) + + // Automatic validation — debounced on every file change (metadata or + // content; the session notifies for both). The relay persists on ~800 ms + // debounce, so waiting slightly longer keeps validate reading the same + // draft the build would. + useEffect(() => { + if (!session || !synced) return + const off = session.onFilesChange(() => { + if (validateTimer.current) clearTimeout(validateTimer.current) + validateTimer.current = setTimeout(() => { + validateTimer.current = null + runValidation() + }, AUTO_VALIDATE_DEBOUNCE_MS) + }) + return () => { + off() + if (validateTimer.current) { + clearTimeout(validateTimer.current) + validateTimer.current = null + } + } + }, [session, synced, runValidation]) + + // First-load validation once synced — diagnostics should not require a + // keystroke to appear. + useEffect(() => { + if (!synced) return + const timer = setTimeout(() => runValidation(), 0) + return () => clearTimeout(timer) + }, [synced, runValidation]) + + /** + * Every lifecycle action has the same shape: a step-up-aware request, a + * success toast, a nudge to open editors (so a new revision's module pack + * or editor entrypoint loads without a reload), then a summary refresh. + * A cancelled step-up is silent; anything else toasts as an error. + */ + const runLifecycle = async ( + request: () => Promise<{ warning?: string }>, + labels: { success: string | null; failure: string }, + hooks: { after?: () => void; onFailure?: () => void } = {}, + ): Promise => { + try { + const result = await runStepUp(request) + if (labels.success) pushToast({ kind: 'success', title: labels.success }) + if (result.warning) { + pushToast({ kind: 'warning', title: 'Completed with a warning', body: result.warning }) + } + notifyCmsPluginsChanged() + ;(hooks.after ?? refreshSummary)() + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ kind: 'error', title: labels.failure, body: getErrorMessage(err, 'Unknown error') }) + hooks.onFailure?.() + } + } + + const lifecycleRequest = + (path: string, init: { method: 'POST' | 'PATCH' | 'DELETE'; body?: unknown }) => + () => + apiRequest(path, { ...init, schema: LifecycleResponseSchema }) + + const activate = async (): Promise => { + setActivating(true) + try { + await runLifecycle( + lifecycleRequest(`/admin/api/cms/site-plugins/${localId}/activate`, { method: 'POST' }), + { success: 'Site plugin activated', failure: 'Build & activate failed' }, + { onFailure: runValidation }, + ) + } finally { + setActivating(false) + } + } + + const rollback = (version: string): Promise => + runLifecycle( + lifecycleRequest(`/admin/api/cms/site-plugins/${localId}/rollback`, { + method: 'POST', + body: { version }, + }), + { success: `Rolled back to v${sitePluginDisplayVersion(version)}`, failure: 'Rollback failed' }, + ) + + const setEnabled = (enabled: boolean): Promise => + runLifecycle( + lifecycleRequest(`/admin/api/cms/plugins/site.${localId}`, { + method: 'PATCH', + body: { enabled }, + }), + { success: null, failure: enabled ? 'Could not activate' : 'Could not deactivate' }, + ) + + const restart = (): Promise => + runLifecycle( + lifecycleRequest(`/admin/api/cms/plugins/site.${localId}/restart`, { method: 'POST' }), + { success: 'Plugin restarted', failure: 'Restart failed' }, + ) + + const deletePlugin = (): Promise => + runLifecycle( + lifecycleRequest(`/admin/api/cms/site-plugins/${localId}`, { method: 'DELETE' }), + { success: 'Site plugin deleted', failure: 'Delete failed' }, + { after: () => navigate('/admin/plugins') }, + ) + + const openPreview = (): void => { + navigate(`/admin/site?previewSitePlugin=${encodeURIComponent(localId)}`) + } + + return { + session, + files, + synced, + generation, + activeFileId, + selectFile: setActiveFileId, + summary, + diagnostics, + validating, + runValidation, + activating, + activate, + rollback, + setEnabled, + restart, + deletePlugin, + openPreview, + } +} diff --git a/src/admin/pages/site/SitePage.tsx b/src/admin/pages/site/SitePage.tsx index 698ee74b5..20bbf789b 100644 --- a/src/admin/pages/site/SitePage.tsx +++ b/src/admin/pages/site/SitePage.tsx @@ -3,6 +3,7 @@ import { AdminCanvasLayout } from '@admin/layouts/AdminCanvasLayout' import { consumePendingAction } from '@admin/spotlight/pendingAction' import { useEditorStore } from '@site/store/store' import { useMcpWorkspaceBridge } from '@admin/ai/useMcpWorkspaceBridge' +import { useDraftModulePackPreview } from '@site/hooks/useDraftModulePackPreview' import { executeAgentTool } from './agent' /** @@ -24,6 +25,11 @@ export function SitePage() { // actually see an active site. useMcpWorkspaceBridge('site', executeAgentTool, undefined, siteHydrated) + // Session-local site plugin draft preview (`?previewSitePlugin=`, + // set by the Plugin IDE's "Preview in canvas") — loads the draft module + // pack into THIS editor session only. + useDraftModulePackPreview() + // Consume cross-workspace pending actions queued by the spotlight. Each // action waits for the editor store to hydrate (site !== null) — we // subscribe once and tear down as soon as the action has fired so the diff --git a/src/admin/pages/site/agent/agentSlice.ts b/src/admin/pages/site/agent/agentSlice.ts index 753ef8681..3bde33981 100644 --- a/src/admin/pages/site/agent/agentSlice.ts +++ b/src/admin/pages/site/agent/agentSlice.ts @@ -205,7 +205,7 @@ function surfaceAssistantError( * Return type is intentionally an `EditorStoreSliceCreator` so * the site editor's existing composition keeps working. The content * workspace's standalone AgentSlice-only store calls it with a small cast - * (see `contentAgentStore.ts`) — both at compile time and at runtime the + * (see `src/admin/ai/createScopedAgentStore.ts`) — both at compile time and at runtime the * slice only touches AgentSlice keys, so wider stores compose cleanly. */ export function createAgentSlice( diff --git a/src/admin/pages/site/agent/codeAssetTools.ts b/src/admin/pages/site/agent/codeAssetTools.ts index 90106ad27..ef98544ca 100644 --- a/src/admin/pages/site/agent/codeAssetTools.ts +++ b/src/admin/pages/site/agent/codeAssetTools.ts @@ -1,6 +1,10 @@ import { aiToolError, aiToolOk, + applyExactReplacements, + hashText, + paginateText, + utf8ByteLength, type AiToolOutput, type InspectCodeRuntimeInput, type ListCodeAssetsInput, @@ -32,8 +36,6 @@ type CodeAssetLookup = { type?: CodeAssetType } -const textEncoder = new TextEncoder() - // Live access to the editor store. Routed through `./storeRef` so this module // has no static import edge back into `editor-store/store.ts`. const getStoreState = (): EditorStore => getAgentStoreApi().getState() @@ -46,13 +48,6 @@ function contentForCodeAsset(file: CodeAssetFile): string { return file.content ?? '' } -async function hashCodeAssetContent(content: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(content)) - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join('') -} - function normalizeCodeAssetPath(path: string): string | null { const normalized = normalizePath(path) return isSafePath(normalized) ? normalized : null @@ -72,8 +67,8 @@ async function describeCodeAsset(store: EditorStore, file: CodeAssetFile) { path: file.path, type: file.type, contentChars: content.length, - bytes: textEncoder.encode(content).byteLength, - hash: await hashCodeAssetContent(content), + bytes: utf8ByteLength(content), + hash: await hashText(content), createdAt: file.createdAt, updatedAt: file.updatedAt, generated: file.generated === true, @@ -158,16 +153,6 @@ function normalizeRequestedRuntimeDependencies( return { ok: true, dependencies } } -function countOccurrences(content: string, search: string): number { - let count = 0 - let index = content.indexOf(search) - while (index !== -1) { - count++ - index = content.indexOf(search, index + search.length) - } - return count -} - function runtimeInspectionPage( store: EditorStore, input: InspectCodeRuntimeInput, @@ -220,31 +205,20 @@ export async function runReadCodeAsset(input: ReadCodeAssetInput): Promise totalParts) { - return aiToolError(`Code asset part ${part} is out of range; totalParts is ${totalParts}.`) - } - - const start = (part - 1) * maxChars - const end = Math.min(content.length, start + maxChars) + const page = paginateText(content, { + part: input.part, + maxChars: input.maxChars, + defaultMaxChars: 12000, + }) + if (!page.ok) return aiToolError(`Code asset ${page.error}`) return aiToolOk({ fileId: resolved.file.id, path: resolved.file.path, type: resolved.file.type, - content: content.slice(start, end), - hash: await hashCodeAssetContent(content), + content: page.content, + hash: await hashText(content), runtime: codeAssetRuntime(store, resolved.file), - pageInfo: { - part, - totalParts, - nextPart: part < totalParts ? part + 1 : null, - maxChars, - start, - end, - totalChars: content.length, - }, + pageInfo: page.pageInfo, }) } @@ -305,37 +279,25 @@ export async function runPatchCodeAsset(input: PatchCodeAssetInput): Promise 1 && replacement.replaceAll !== true) { - return aiToolError( - `Replacement for ${resolved.file.path} is ambiguous: ${matches} matches. ` + - 'Use a larger oldText span or set replaceAll:true.', - ) - } - - if (replacement.replaceAll === true) { - nextContent = nextContent.split(replacement.oldText).join(replacement.newText) - replacementCount += matches - } else { - nextContent = nextContent.replace(replacement.oldText, replacement.newText) - replacementCount += 1 - } + const applied = applyExactReplacements(currentContent, input.replacements) + if (!applied.ok) { + return aiToolError( + applied.reason === 'not-found' + ? `Replacement text not found in ${resolved.file.path}.` + : `Replacement for ${resolved.file.path} is ambiguous: ${applied.matches} matches. ` + + 'Use a larger oldText span or set replaceAll:true.', + ) } + const replacementCount = applied.replaced - store.updateFileContent(resolved.file.id, nextContent) + store.updateFileContent(resolved.file.id, applied.content) const afterStore = getStoreState() const file = afterStore.site?.files.find((candidate) => candidate.id === resolved.file.id) if (!file || !isCodeAssetFile(file)) { diff --git a/src/admin/pages/site/code-editor/AgentCodeView.tsx b/src/admin/pages/site/code-editor/AgentCodeView.tsx new file mode 100644 index 000000000..b5c6cbbac --- /dev/null +++ b/src/admin/pages/site/code-editor/AgentCodeView.tsx @@ -0,0 +1,98 @@ +/** + * AgentCodeView — read-only CodeMirror code + unified-diff blocks for the + * agent panel's tool rows (what did `plugin_write_file` write, what did + * `plugin_patch_file` change). + * + * LAZY-LOADED via React.lazy() in ToolCallRow — this module MUST NOT be + * imported statically from the admin main chunk (CodeMirror is ~150 kB + * min+gz; the codemirror-lazy-only gate pins the allowed importers). It + * shares the editor chrome theme + language stacks with CodeMirrorEditor. + * + * `diffOriginal` switches the block into a unified merge view + * (@codemirror/merge): deletions render inline above their replacements — + * a real diff, not two stacked snippets. + */ +import { useEffect, useRef } from 'react' +import { EditorView, lineNumbers } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { syntaxHighlighting } from '@codemirror/language' +import { unifiedMergeView } from '@codemirror/merge' +import { + editorChromeTheme, + getLanguageExtensions, + readableHighlightStyle, + type CodeLanguage, +} from './codeMirrorShared' + +/** Map a file path to the highlight language by extension. */ +function codeLanguageForPath(path: string): CodeLanguage { + const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase() + switch (extension) { + case 'tsx': + case 'jsx': + return 'tsx' + case 'ts': + case 'js': + case 'mjs': + return 'ts' + case 'css': + return 'css' + case 'json': + return 'json' + case 'md': + return 'markdown' + case 'html': + case 'svg': + return 'html' + default: + return 'text' + } +} + +interface AgentCodeViewProps { + /** The (new) code to display. */ + code: string + /** Path used only to pick the highlight language. */ + path: string + /** When set, render a unified diff from this original to `code`. */ + diffOriginal?: string +} + +export default function AgentCodeView({ code, path, diffOriginal }: AgentCodeViewProps) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + const view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: code, + extensions: [ + // Chat rows are denser than the editor — drop the base 12px to + // the fluid --text-xs token (~10-11px). + EditorView.theme({ '&': { fontSize: 'var(--text-xs)' } }), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.lineWrapping, + lineNumbers(), + editorChromeTheme, + syntaxHighlighting(readableHighlightStyle), + ...getLanguageExtensions(codeLanguageForPath(path)), + ...(diffOriginal !== undefined + ? [ + unifiedMergeView({ + original: diffOriginal, + mergeControls: false, + gutter: true, + }), + ] + : []), + ], + }), + }) + return () => view.destroy() + }, [code, path, diffOriginal]) + + return
+} diff --git a/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx b/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx index 28a8161d6..a7d8511b7 100644 --- a/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx +++ b/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx @@ -29,21 +29,15 @@ */ import { useRef, useEffect, useEffectEvent, useCallback } from 'react' -import { EditorView, basicSetup } from 'codemirror' +import { EditorView } from '@codemirror/view' import { EditorState } from '@codemirror/state' -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { syntaxHighlighting } from '@codemirror/language' import { autocompletion, type CompletionContext, type CompletionResult, } from '@codemirror/autocomplete' import { hoverTooltip, tooltips, type Tooltip } from '@codemirror/view' -import { javascript } from '@codemirror/lang-javascript' -import { css } from '@codemirror/lang-css' -import { json } from '@codemirror/lang-json' -import { markdown } from '@codemirror/lang-markdown' -import { html } from '@codemirror/lang-html' -import { tags as t } from '@lezer/highlight' import type { Extension } from '@codemirror/state' import { lintGutter, setDiagnostics, type Diagnostic } from '@codemirror/lint' import type { SiteRuntimeDiagnostic } from '@core/site-runtime' @@ -56,227 +50,16 @@ import type { TypeScriptProjectFile, } from './typescriptProtocol' import { renderMarkdownDocumentation } from './markdownDocumentation' +import { + editingSetup, + editorChromeTheme, + getLanguageExtensions, + readableHighlightStyle, + type CodeLanguage, +} from './codeMirrorShared' + +export type { CodeLanguage } from './codeMirrorShared' -// --------------------------------------------------------------------------- -// GitHub Dark-inspired CM6 theme — CSS custom properties only. -// --------------------------------------------------------------------------- -// All color values are CSS custom properties from globals.css. -// No hex, rgb(), or hsl() literals in this lazy-loaded editor module. -const achromatic = EditorView.theme({ - '&': { - backgroundColor: 'var(--bg-surface)', - color: 'var(--text)', - height: '100%', - fontSize: '12px', - fontFamily: 'var(--font-mono)', - }, - '&.cm-focused': { - outline: 'none', - }, - '.cm-content': { - caretColor: 'var(--overlay)', - padding: 'var(--space-s) 0', - }, - '.cm-cursor': { - borderLeftColor: 'var(--overlay)', - }, - '.cm-selectionBackground': { - backgroundColor: 'var(--overlay-10)', - }, - '&.cm-focused .cm-selectionBackground': { - backgroundColor: 'var(--overlay-10)', - }, - '.cm-gutters': { - backgroundColor: 'var(--bg-surface-3)', - borderRight: '1px solid var(--overlay-10)', - color: 'var(--text-disabled)', - }, - '.cm-gutter': { - minWidth: '3ch', - }, - '.cm-lineNumbers .cm-gutterElement': { - color: 'var(--text-disabled)', - fontSize: '11px', - }, - '.cm-activeLine': { - backgroundColor: 'rgba(255, 255, 255, 0.03)', - }, - '.cm-activeLineGutter': { - backgroundColor: 'rgba(255, 255, 255, 0.04)', - color: 'var(--text-subtle)', - }, - '.cm-line': { - padding: '0 var(--space-l) 0 var(--space-3xs)', - }, - '.cm-tooltip': { - backgroundColor: 'var(--bg-surface-2)', - border: '1px solid var(--overlay-10)', - color: 'var(--text)', - }, - '.cm-typescript-hover': { - maxWidth: 'min(340px, calc(100vw - 32px))', - padding: 'var(--space-s) var(--space-m)', - fontFamily: 'var(--font-mono)', - fontSize: 'var(--text-xs)', - lineHeight: '1.5', - overflowWrap: 'anywhere', - }, - '.cm-typescript-hover-signature': { - color: 'var(--syntax-entity)', - whiteSpace: 'pre-wrap', - }, - '.cm-typescript-hover-documentation': { - marginTop: 'var(--space-xs)', - color: 'var(--text-muted)', - fontFamily: 'var(--font-sans)', - whiteSpace: 'pre-wrap', - }, - '.cm-typescript-hover-documentation p': { - margin: '0', - }, - '.cm-typescript-hover-documentation p + p': { - marginTop: 'var(--space-xs)', - }, - '.cm-typescript-hover-documentation strong': { - color: 'var(--text)', - fontWeight: '600', - }, - '.cm-typescript-hover-documentation code': { - padding: '0 var(--space-3xs)', - borderRadius: 'var(--radius-sm)', - backgroundColor: 'var(--overlay-10)', - color: 'var(--syntax-string)', - fontFamily: 'var(--font-mono)', - }, - '.cm-typescript-hover-documentation a': { - color: 'var(--syntax-constant)', - textDecoration: 'underline', - textUnderlineOffset: '2px', - }, - '.cm-lintRange-error': { - textDecorationColor: 'var(--danger)', - }, - '.cm-lint-marker-error': { - color: 'var(--danger)', - }, -}, { dark: true }) - -const readableHighlightStyle = HighlightStyle.define([ - { - tag: [ - t.comment, - t.lineComment, - t.blockComment, - t.docComment, - t.meta, - ], - color: 'var(--syntax-comment)', - fontStyle: 'italic', - }, - { - tag: [ - t.keyword, - t.definitionKeyword, - t.operatorKeyword, - t.modifier, - t.controlKeyword, - ], - color: 'var(--syntax-keyword)', - fontWeight: '600', - }, - { - tag: [ - t.labelName, - t.typeName, - t.className, - t.namespace, - t.macroName, - t.tagName, - t.function(t.variableName), - t.function(t.propertyName), - ], - color: 'var(--syntax-entity)', - }, - { - tag: [ - t.propertyName, - t.definition(t.propertyName), - t.attributeName, - ], - color: 'var(--syntax-property)', - }, - { - tag: [ - t.variableName, - t.definition(t.variableName), - t.local(t.variableName), - t.special(t.variableName), - ], - color: 'var(--syntax-variable)', - }, - { - tag: [ - t.atom, - t.bool, - t.number, - t.integer, - t.float, - t.unit, - t.color, - t.url, - t.literal, - t.contentSeparator, - ], - color: 'var(--syntax-constant)', - }, - { - tag: [ - t.string, - t.regexp, - t.escape, - t.special(t.string), - t.inserted, - t.deleted, - ], - color: 'var(--syntax-string)', - }, - { - tag: [ - t.operator, - t.arithmeticOperator, - t.logicOperator, - t.compareOperator, - t.definitionOperator, - t.derefOperator, - t.punctuation, - t.separator, - t.bracket, - t.paren, - t.squareBracket, - t.brace, - ], - color: 'var(--syntax-operator)', - }, - { - tag: [t.heading, t.strong], - color: 'var(--syntax-entity)', - fontWeight: '700', - }, - { - tag: [t.emphasis], - color: 'var(--syntax-string)', - fontStyle: 'italic', - }, - { - tag: [t.link], - color: 'var(--syntax-constant)', - textDecoration: 'underline', - }, - { - tag: t.invalid, - color: 'var(--syntax-invalid)', - }, -], { themeType: 'dark' }) const readableSyntaxHighlighting = syntaxHighlighting(readableHighlightStyle) @@ -284,48 +67,6 @@ const readableSyntaxHighlighting = syntaxHighlighting(readableHighlightStyle) // Per-type extension stacks // --------------------------------------------------------------------------- -/** - * The set of languages the editor can syntax-highlight. Callers map their - * source (a SiteFile's type/path, or an arbitrary code buffer like an inline - * SVG prop) to one of these — keeping the CM6 language imports inside this - * lazy-loaded chunk. - */ -export type CodeLanguage = - | 'tsx' - | 'ts' - | 'jsx' - | 'javascript' - | 'css' - | 'json' - | 'markdown' - | 'html' - | 'text' - -/** Map a `CodeLanguage` to its CM6 language extension(s). */ -function getLanguageExtensions(language: CodeLanguage): Extension[] { - switch (language) { - case 'tsx': - return [javascript({ jsx: true, typescript: true })] - case 'ts': - return [javascript({ typescript: true })] - case 'jsx': - return [javascript({ jsx: true })] - case 'javascript': - return [javascript()] - case 'css': - return [css()] - case 'json': - return [json()] - case 'markdown': - return [markdown()] - case 'html': - // Used for inline SVG markup (SVG is HTML-compatible XML). - return [html()] - case 'text': - default: - return [] - } -} // --------------------------------------------------------------------------- // CodeMirrorEditor @@ -342,13 +83,30 @@ interface CodeMirrorEditorProps { value: string /** Which language extensions to load for highlighting. */ language: CodeLanguage - /** Debounced (250 ms) on every edit, and flushed immediately on docKey switch. */ - onChange: (content: string) => void + /** + * Debounced (250 ms) on every edit, and flushed immediately on docKey + * switch. Omit it when something else owns persistence (the co-edited + * buffer's Y binding): no listener is installed and nothing is buffered. + */ + onChange?: (content: string) => void /** * Change propagation delay. File editors keep the 250 ms default; modal * command surfaces can pass 0 so their primary action never reads stale text. */ changeDelayMs?: number + /** + * Extra CM6 extensions appended to the stack — the Plugin IDE passes the + * yCollab binding (Y.Text sync + remote peer cursors) here. Captured at + * mount like `value`/`language`; changing them mid-doc requires a docKey + * change (which remounts the view). + */ + extensions?: Extension[] + /** + * Whether the buffer keeps CodeMirror's own undo history (default). The + * co-edited buffer passes false: its undo is the Y.UndoManager, and a + * local history would record remote peers' edits as undoable steps. + */ + localHistory?: boolean /** Authoritative publisher-compiler diagnostics for this document. */ diagnostics?: SiteRuntimeDiagnostic[] /** Site-relative path used by the TypeScript language-service project. */ @@ -504,6 +262,8 @@ export default function CodeMirrorEditor({ filePath, projectFiles = EMPTY_PROJECT_FILES, onTypeScriptDiagnosticsChange, + extensions, + localHistory = true, }: CodeMirrorEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) @@ -538,7 +298,7 @@ export default function CodeMirrorEditor({ } if (pendingContentRef.current !== null) { // Flush-on-switch: persist pending edit before unmounting. - onChangeRef.current(pendingContentRef.current) + onChangeRef.current?.(pendingContentRef.current) pendingContentRef.current = null } }, []) @@ -563,7 +323,7 @@ export default function CodeMirrorEditor({ state: EditorState.create({ doc: value, extensions: [ - basicSetup, + ...editingSetup({ localHistory }), ...getLanguageExtensions(language), ...(typeScriptClient && filePath ? [ @@ -572,12 +332,14 @@ export default function CodeMirrorEditor({ ] : []), readableSyntaxHighlighting, - achromatic, + editorChromeTheme, editorTooltipBoundary, lintGutter(), EditorView.updateListener.of((update) => { if (!update.docChanged) return const content = update.state.doc.toString() + // The language service tracks the buffer even in a read-only + // view (no `onChange`), so diagnostics stay honest. if (typeScriptClient && filePath) { typeScriptClient.updateFile(filePath, content) if (typeScriptDiagnosticsTimer) clearTimeout(typeScriptDiagnosticsTimer) @@ -586,26 +348,28 @@ export default function CodeMirrorEditor({ typeScriptDiagnosticsTimer = null }, 300) } + if (!onChange) return if (changeDelayMs <= 0) { if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } pendingContentRef.current = null - onChangeRef.current(content) + onChangeRef.current?.(content) return } pendingContentRef.current = content if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { if (pendingContentRef.current !== null) { - onChangeRef.current(pendingContentRef.current) + onChangeRef.current?.(pendingContentRef.current) pendingContentRef.current = null } timerRef.current = null }, changeDelayMs) }), EditorView.lineWrapping, + ...(extensions ?? []), ], }), parent: container, diff --git a/src/admin/pages/site/code-editor/CollabCodeMirrorEditor.tsx b/src/admin/pages/site/code-editor/CollabCodeMirrorEditor.tsx new file mode 100644 index 000000000..5547a4ff0 --- /dev/null +++ b/src/admin/pages/site/code-editor/CollabCodeMirrorEditor.tsx @@ -0,0 +1,117 @@ +/** + * CollabCodeMirrorEditor — the co-editing CodeMirror mount. + * + * A sibling lazy module to CodeMirrorEditor (same React.lazy rule — this + * file and CodeMirrorEditor.tsx are the only static CodeMirror importers, + * gated by codemirror-lazy-only.test.ts). Wraps the base editor with + * y-codemirror.next's `yCollab` extension: local keystrokes splice the + * bound Y.Text, remote peers' edits apply character-precise, and their + * carets/selections render inline colored by each peer's awareness + * identity. Undo is the passed Y.UndoManager — local-only by construction: + * the base editor mounts WITHOUT its own `history()` (which would record + * remote deltas as undoable steps), and the Y undo keymap takes precedence + * over every other Mod-z binding in the stack. + */ +import { EditorView, keymap } from '@codemirror/view' +import { EditorState, Prec, type Extension } from '@codemirror/state' +import type * as Y from 'yjs' +import type { Awareness } from 'y-protocols/awareness' +import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next' +import CodeMirrorEditor, { type CodeLanguage } from './CodeMirrorEditor' + +/** Remote peer carets/selections — colors ride each peer's awareness + * `user.color`; class names come from y-codemirror.next. */ +const remotePeerTheme = EditorView.theme({ + '.cm-yLineSelection': { + padding: 0, + margin: '0 var(--space-3xs)', + }, + '.cm-ySelectionCaret': { + position: 'relative', + borderLeft: '1px solid', + borderRight: '1px solid', + marginLeft: '-1px', + marginRight: '-1px', + boxSizing: 'border-box', + display: 'inline', + }, + '.cm-ySelectionCaretDot': { + borderRadius: '50%', + position: 'absolute', + width: '.4em', + height: '.4em', + top: '-.2em', + left: '-.2em', + backgroundColor: 'inherit', + transition: 'transform .3s ease-in-out', + boxSizing: 'border-box', + }, + '.cm-ySelectionCaret:hover > .cm-ySelectionCaretDot': { + transform: 'scale(0)', + }, + '.cm-ySelectionInfo': { + position: 'absolute', + top: '-1.05em', + left: '-1px', + fontSize: 'var(--text-2xs)', + fontFamily: 'var(--font-sans)', + fontStyle: 'normal', + fontWeight: 'normal', + lineHeight: 'normal', + userSelect: 'none', + color: 'var(--bg-surface)', + paddingLeft: 'var(--space-px)', + paddingRight: 'var(--space-px)', + zIndex: 101, + transition: 'opacity .3s ease-in-out', + backgroundColor: 'inherit', + opacity: 0, + transitionDelay: '0s', + whiteSpace: 'nowrap', + }, + '.cm-ySelectionCaret:hover > .cm-ySelectionInfo': { + opacity: 1, + transitionDelay: '0s', + }, +}) + +interface CollabCodeMirrorEditorProps { + /** Stable identity — switching remounts the underlying view. */ + docKey: string + language: CodeLanguage + /** The live CRDT text this buffer edits. */ + text: Y.Text + awareness: Awareness + /** Local-only undo; false disables the binding's undo integration. */ + undoManager: Y.UndoManager | false + readOnly: boolean +} + +export default function CollabCodeMirrorEditor({ + docKey, + language, + text, + awareness, + undoManager, + readOnly, +}: CollabCodeMirrorEditorProps) { + // Recomputed per render is fine: CodeMirrorEditor captures `value` and + // `extensions` at mount and only remounts when `docKey` changes. + const extensions: Extension[] = [ + Prec.high(keymap.of(yUndoManagerKeymap)), + yCollab(text, awareness, { undoManager }), + remotePeerTheme, + ...(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []), + ] + + return ( + + ) +} diff --git a/src/admin/pages/site/code-editor/ScriptSettingsPane.module.css b/src/admin/pages/site/code-editor/ScriptSettingsPane.module.css index 0defa1e1a..02dda48b3 100644 --- a/src/admin/pages/site/code-editor/ScriptSettingsPane.module.css +++ b/src/admin/pages/site/code-editor/ScriptSettingsPane.module.css @@ -110,3 +110,16 @@ border-bottom: 1px solid var(--border); } } + +.placementHint { + margin: 0; + padding: var(--space-xs) 0; + font-size: var(--text-xs); + line-height: 1.5; + color: var(--text-muted); +} + +.placementHint code { + font-family: var(--font-mono); + font-size: var(--text-2xs); +} diff --git a/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx b/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx index e05374ccb..78844d2f6 100644 --- a/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx +++ b/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx @@ -50,6 +50,11 @@ export function ScriptSettingsPane({ file }: ScriptSettingsPaneProps) { return (