From b93ca0814a90e761b818c3198f621f35a6a07722 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 10:43:14 -0700 Subject: [PATCH] Clarify runtime organization and consolidate plugin opening --- README.md | 3 + cloudflare/artifact-service.ts | 32 +++ cloudflare/mcp.ts | 2 +- cloudflare/script-guide.ts | 17 ++ cloudflare/script-service.ts | 127 +++++++++++ cloudflare/service.ts | 202 +++--------------- cloudflare/tool-contract.ts | 2 +- docs/architecture.md | 53 +++++ docs/releasing.md | 2 +- e2e/mcp-app.test.ts | 2 +- herdr-plugin.toml | 2 +- package.json | 62 +++--- scripts/prepare-cloudflare.ts | 2 +- src/actions/open.ts | 21 -- src/cli.test.ts | 27 ++- src/cli.ts | 10 +- .../celld-runtime.test.ts} | 2 +- .../celld-runtime.ts} | 0 .../preview-daemon.test.ts} | 6 +- src/{daemon.ts => local/preview-daemon.ts} | 6 +- .../server-daemon.test.ts} | 4 +- .../server-daemon.ts} | 4 +- src/{celldServer.ts => local/server.ts} | 4 +- .../app-contract.ts} | 0 src/{mcpApp.test.ts => mcp/app.test.ts} | 12 +- src/{mcpApp.ts => mcp/app.ts} | 16 +- src/{canvasGuide.ts => mcp/guide.ts} | 0 src/{mcp.test.ts => mcp/local-tools.test.ts} | 14 +- src/{mcp.ts => mcp/local-tools.ts} | 14 +- src/{mcpProtocol.ts => mcp/protocol.ts} | 0 src/{mcpServer.ts => mcp/stdio.ts} | 6 +- src/{mcpTools.ts => mcp/tools.ts} | 2 +- src/open.ts | 2 +- src/runtime/mcp-app.ts | 2 +- 34 files changed, 380 insertions(+), 280 deletions(-) create mode 100644 cloudflare/artifact-service.ts create mode 100644 cloudflare/script-guide.ts create mode 100644 cloudflare/script-service.ts create mode 100644 docs/architecture.md delete mode 100644 src/actions/open.ts rename src/{celldRuntime.test.ts => local/celld-runtime.test.ts} (99%) rename src/{celldRuntime.ts => local/celld-runtime.ts} (100%) rename src/{daemon.test.ts => local/preview-daemon.test.ts} (95%) rename src/{daemon.ts => local/preview-daemon.ts} (96%) rename src/{celldDaemon.test.ts => local/server-daemon.test.ts} (99%) rename src/{celldDaemon.ts => local/server-daemon.ts} (99%) rename src/{celldServer.ts => local/server.ts} (97%) rename src/{mcpAppContract.ts => mcp/app-contract.ts} (100%) rename src/{mcpApp.test.ts => mcp/app.test.ts} (95%) rename src/{mcpApp.ts => mcp/app.ts} (94%) rename src/{canvasGuide.ts => mcp/guide.ts} (100%) rename src/{mcp.test.ts => mcp/local-tools.test.ts} (94%) rename src/{mcp.ts => mcp/local-tools.ts} (95%) rename src/{mcpProtocol.ts => mcp/protocol.ts} (100%) rename src/{mcpServer.ts => mcp/stdio.ts} (88%) rename src/{mcpTools.ts => mcp/tools.ts} (99%) diff --git a/README.md b/README.md index 7c34e38..74428d6 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,9 @@ or delete action; restoring working source remains an explicit CLI/MCP operation ## Development +See the [source map](docs/architecture.md) for runtime boundaries, MCP modules, +hosted services, and Herdr action entrypoints. + ```bash bun run test bun run test:e2e diff --git a/cloudflare/artifact-service.ts b/cloudflare/artifact-service.ts new file mode 100644 index 0000000..fb10a74 --- /dev/null +++ b/cloudflare/artifact-service.ts @@ -0,0 +1,32 @@ +import type { DurableObjectStub, DurableObjectNamespace } from "@cloudflare/workers-types"; +import type { ArtifactLinks, ArtifactTarget } from "./links"; +import type { ScriptLibrary } from "./scripts"; +import type { ScriptBackend } from "./script-backend"; + +export type HostedArtifacts = { + links: DurableObjectStub; + scripts: DurableObjectStub; + scriptBackends: DurableObjectNamespace; + origin: string; +}; + +/** Shared artifact identity and URL metadata for canvas and script operations. */ +export class ArtifactService { + constructor(readonly workspace: string, readonly libraryKey: string, readonly hosted?: HostedArtifacts) {} + + requireHosted(): HostedArtifacts { + if (!this.hosted) throw new Error("Standalone artifacts are unavailable in this runtime"); + return this.hosted; + } + + target(kind: "canvas" | "script", name: string): ArtifactTarget { + return { libraryKey: this.libraryKey, workspace: this.workspace, kind, name: name.trim().replace(kind === "canvas" ? /\.canvas\.tsx$/ : /\.script\.ts$/, "") }; + } + + async linkDetails(target: ArtifactTarget) { + const link = await this.hosted?.links.find(target); + if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.origin}/${link.slug}` }; + const pending = await this.hosted?.links.draft(target); + return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {}; + } +} diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index fa5a166..0ccc471 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -5,7 +5,7 @@ import { ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; -import { CANVAS_APP_URI, CANVAS_RESOURCE } from "../src/mcpAppContract"; +import { CANVAS_APP_URI, CANVAS_RESOURCE } from "../src/mcp/app-contract"; import shell from "../dist/cloudflare/mcp-app.json"; import * as validators from "../dist/cloudflare/tool-validators.js"; import { CLOUD_MCP_TOOLS } from "./tool-contract"; diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts new file mode 100644 index 0000000..07f3615 --- /dev/null +++ b/cloudflare/script-guide.ts @@ -0,0 +1,17 @@ +export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs + +Canvases and scripts coexist. Use canvas_write with an optional slug, or artifact_link({kind:"canvas",name,slug,access}), to give a canvas a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated. + +Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Unbundled third-party imports, relative modules and package.json dependency installation are not supported by script_write. The server accepts one self-contained module, at most 256 KiB of UTF-8 source. + +Use script_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) to set/delete values, or omit secrets to list names. Values never appear in source history or management reads. Use env.secrets.TOKEN inside the handler. Avoid returning secrets in HTTP responses. Initialize database tables with create table if not exists; source edits/restores preserve the database. + +script_write and script_edit save source before validation. Inspect applied, ok, and diagnostics: failed drafts remain editable, while the URL keeps serving the last validated code. No separate publish operation is needed. script_read reads draft or historical source, script_history lists revisions, script_version reads one, and script_restore restores and validates a prior revision. script_list, script_read, and gallery views never execute handlers. + +script_run({name,request:{path:"/chosen-slug?key=value",method:"POST",headers:[["content-type","text/plain"]],body:"aGVsbG8="}}) explicitly invokes the last validated handler. The request path and query are passed exactly as provided, using the deployment origin; include the slug in path to reproduce a direct URL request. Body is base64. MCP requests and responses are limited to 256 KiB; use direct URLs for streaming or larger responses. script_logs reads bounded recent execution logs and errors. Direct URLs preserve incoming paths, methods and body bytes and return the handler response. Management cookies and Cloudflare Access credentials are stripped; private routes also strip Authorization. Public Authorization is passed to the handler. Script HTML gets an opaque-origin sandbox and responses cannot set cookies. Public handlers can implement their own authentication, including provider signature checks. Cloudflare Access policies protecting the entire hostname need a bypass for intended public paths. No schedules or separate publishing workflow are provided. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction. + +Third-party dependencies +Keep the editable source, package.json and lockfile locally. Install pinned packages with bun add --exact @. Typecheck your original TypeScript before bundling. Bundle locally with Bun.build({entrypoints:["handler.ts"],target:"browser",format:"esm",external:["node:*","cloudflare:*"],banner:"// @ts-nocheck",minify:true}). Check build.success, then read build.outputs[0].text() and pass that entire string as script_write.contents. The generated-JavaScript banner avoids implicit-any errors after type erasure; it is only for the generated bundle, not handwritten source. Retain your original sources and rebuild after edits rather than editing bundled internals. Workers cannot execute arbitrary Node processes/native addons; use Workers-compatible packages. If the bundle exceeds 256 KiB, reduce the dependency footprint; splitting it into relative imports is not supported. + +After writing, check ok/diagnostics and the returned URL. Creation loads the module for validation but does not invoke the handler. Explicit script_run or an HTTP request invokes it and may mutate state or call external services. Inspect the HTTP status/body and script_logs before reporting the handler working. script_guide and canvas_guide describe the hosted contracts; local Bun stdio does not expose script tools. +`; diff --git a/cloudflare/script-service.ts b/cloudflare/script-service.ts new file mode 100644 index 0000000..b8e15c0 --- /dev/null +++ b/cloudflare/script-service.ts @@ -0,0 +1,127 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { createHash } from "node:crypto"; +import type { CanvasEdit } from "./library"; +import type { ArtifactService } from "./artifact-service"; +import type { LinkUpdate } from "./links"; +import type { CanvasHttpRequest } from "../src/httpTypes"; +import { formatCanvasCheck } from "../src/diagnostics"; +import { nativeRequest } from "./backend"; +import { compileScriptSource } from "./compiler"; +import { scriptResponse } from "./script-service-http"; +import { HOSTED_SCRIPT_GUIDE } from "./script-guide"; + +export class CloudScriptService { + constructor(private readonly artifacts: ArtifactService) {} + + async readSource(selection: { name?: string; version_id?: string }) { + const scripts = this.artifacts.requireHosted().scripts; + if (selection.version_id) { + const version = await scripts.version({ workspace: this.artifacts.workspace, id: selection.version_id }); + if (selection.name && version.name !== this.artifacts.target("script", selection.name).name) throw new Error("Version does not belong to this script"); + return { ...version, path: `${this.artifacts.workspace}/${version.name}.script.ts` }; + } + if (!selection.name) throw new Error("name or version_id is required"); + return scripts.readRange({ workspace: this.artifacts.workspace, name: selection.name, end_line: Number.MAX_SAFE_INTEGER }); + } + + async callTool(tool: string, args: Record): Promise { + const hosted = this.artifacts.requireHosted(), scripts = hosted.scripts; + const name = args.name as string, input = { workspace: this.artifacts.workspace, name }; + const offset = args.offset as number | undefined ?? 0; + switch (tool) { + case "script_guide": return { content: [{ type: "text", text: HOSTED_SCRIPT_GUIDE }] }; + case "script_list": { + const drafts = await scripts.listDrafts({ workspace: this.artifacts.workspace, offset }); + const entries = await Promise.all(drafts.map(async draft => ({ ...draft, ...await this.artifacts.linkDetails(this.artifacts.target("script", draft.name)) }))); + return text({ scripts: entries, next_offset: entries.length === 100 ? offset + 100 : null }); + } + case "script_read": { + if (!args.version_id) return text(await scripts.readRange({ ...input, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); + const version = await this.readSource({ name, version_id: args.version_id as string }); + const lines = version.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; + const start = args.start_line as number | undefined ?? 1, end = args.end_line as number | undefined ?? start + 199; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || start > lines.length) throw new Error("invalid line range"); + const actualEnd = Math.min(end, lines.length); + return text({ ...version, source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null }); + } + case "script_history": { + const versions = await scripts.history({ workspace: this.artifacts.workspace, name, offset }); + return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); + } + case "script_version": return text(await scripts.version({ workspace: this.artifacts.workspace, id: args.version_id as string })); + case "script_logs": return text({ logs: await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).logs({ limit: args.limit as number | undefined }) }); + case "script_secrets": { + if (args.secrets !== undefined) { + if (!args.secrets || typeof args.secrets !== "object" || Array.isArray(args.secrets)) throw new Error("secrets must be an object"); + for (const [key, value] of Object.entries(args.secrets)) await scripts.setSecret({ ...input, key, value: value as string | null }); + } + return text({ ok: true, names: await scripts.secretNames(input) }); + } + case "script_run": { + const link = await hosted.links.find(this.artifacts.target("script", name)); + if (!link?.script_hash) throw new Error("Script has no validated URL revision"); + const active = await scripts.active({ ...input, hash: link.script_hash }); + const incoming = nativeRequest(args.request as CanvasHttpRequest); + const request = new Request(new URL(new URL(incoming.url).pathname + new URL(incoming.url).search, hosted.origin), incoming); + const response = await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).request({ ...active, request }); + const envelope = await scriptResponse(response, incoming.method); + return { ...text({ status: envelope.status, ...(link ? { url: `${hosted.origin}/${link.slug}` } : {}) }), structuredContent: { response: envelope } }; + } + case "script_write": case "script_edit": case "script_restore": { + const target = this.artifacts.target("script", name); + // Read pending intent only after taking this operation's generation. + // A concurrent explicit access change then either precedes this read or + // supersedes this generation; it cannot be undone by stale metadata. + const generation = await hosted.links.begin(target); + const settings: LinkUpdate = await hosted.links.draft(target); + if (tool === "script_write") { + const previous = await hosted.links.find(target); + settings.slug = args.slug as string | undefined ?? settings.slug ?? previous?.slug ?? target.name; + if (args.access !== undefined) settings.access = args.access as "private" | "public"; + } + if (settings.slug !== undefined) await hosted.links.check(target, settings.slug); + if (tool === "script_restore") { + const version = await scripts.version({ workspace: this.artifacts.workspace, id: args.version_id as string }); + if (version.name !== target.name) throw new Error("Version does not belong to this script"); + } + const mutation = tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string }) + : tool === "script_edit" ? await scripts.editDraft({ ...input, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }) + : await scripts.restore({ workspace: this.artifacts.workspace, id: args.version_id as string }); + await hosted.links.stage(target, generation, settings); + const compiled = await compileScriptSource(mutation.source); + if (compiled.ok && compiled.js) { + const secrets = await scripts.executionSecrets(input); + const backend = hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, target.name])); + const validation = await backend.validate({ code: compiled.js, hash: createHash("sha256").update(compiled.js).digest("hex"), secrets }); + if (validation.ok) { + try { + const activated = await scripts.activate({ ...input, source_hash: mutation.source_hash, code: compiled.js }); + const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash }); + if (!link) { + const { source, ...summary } = mutation; + const superseded = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; + return { ...text(superseded, true), structuredContent: superseded }; + } + } catch (error) { + const { source, ...summary } = mutation; + const message = error instanceof Error ? error.message : "Script URL activation failed"; + const failed = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, ...(message.includes("changed during validation") ? { superseded: true } : {}), error: message }; + return { ...text(failed, true), structuredContent: failed }; + } + } else { + compiled.ok = false; + compiled.diagnostics.push({ severity: "error", message: "Script module could not initialize" }); + } + } + const { source, ...summary } = mutation; + const payload = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + return { ...text(payload, !compiled.ok), structuredContent: payload }; + } + default: throw new Error("Unknown script tool"); + } + } +} + +function text(value: unknown, isError = false): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError }; +} diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 7e004c7..02a1078 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -3,20 +3,18 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CanvasLibrary, CanvasEdit } from "./library"; import { compileCanvasSource, compileCanvasServerSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; import type { CanvasBackend } from "./backend"; -import { nativeRequest } from "./backend"; -import type { ArtifactLinks, ArtifactTarget, LinkUpdate } from "./links"; -import type { ScriptLibrary } from "./scripts"; -import type { ScriptBackend } from "./script-backend"; -import { compileScriptSource } from "./compiler"; -import { scriptResponse } from "./script-service-http"; -export type HostedArtifacts = { links: DurableObjectStub; scripts: DurableObjectStub; scriptBackends: DurableObjectNamespace; origin: string }; +import type { LinkUpdate } from "./links"; +import { ArtifactService, type HostedArtifacts } from "./artifact-service"; +import { CloudScriptService } from "./script-service"; +import { HOSTED_SCRIPT_GUIDE } from "./script-guide"; +export type { HostedArtifacts } from "./artifact-service"; import type { CanvasHttpRequest } from "../src/httpTypes"; import { createHash } from "node:crypto"; import { formatCanvasCheck } from "../src/diagnostics"; -import type { CanvasAppPayload } from "../src/mcpAppContract"; +import type { CanvasAppPayload } from "../src/mcp/app-contract"; import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { runtime } from "../dist/cloudflare/identity.json"; -import { canvasGuideResult } from "../src/canvasGuide"; +import { canvasGuideResult } from "../src/mcp/guide"; type Snapshot = { workspace: string; name: string; path: string; source: string; @@ -27,11 +25,17 @@ type Mutation = Snapshot & { ok: boolean; applied?: boolean; changed?: boolean; type Compiled = Awaited>; export class CloudCanvasService { + private readonly artifacts: ArtifactService; + private readonly scripts: CloudScriptService; + constructor(readonly library: DurableObjectStub, readonly workspace: string, - readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts) {} + readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts) { + this.artifacts = new ArtifactService(workspace, libraryKey, hosted); + this.scripts = new CloudScriptService(this.artifacts); + } async callTool(name: string, args: Record): Promise { - if (name.startsWith("script_")) return this.scriptTool(name, args); + if (name.startsWith("script_")) return this.scripts.callTool(name, args); if (name === "artifact_link") return this.artifactLink(args); switch (name) { case "canvas_guide": { @@ -42,7 +46,7 @@ export class CloudCanvasService { case "canvas_list": { const offset = args.offset as number | undefined ?? 0; const canvases = await this.library.listDrafts({ workspace: this.workspace, offset }); - const entries = await Promise.all(canvases.map(async canvas => ({ ...canvas, ...await this.linkDetails(this.target("canvas", canvas.id)) }))); + const entries = await Promise.all(canvases.map(async canvas => ({ ...canvas, ...await this.artifacts.linkDetails(this.artifacts.target("canvas", canvas.id)) }))); return text({ canvases: entries, next_offset: canvases.length === 100 ? offset + 100 : null }); } case "canvas_read": return text(await this.library.readRange({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); @@ -53,18 +57,18 @@ export class CloudCanvasService { } case "canvas_version": return text(await this.library.version({ workspace: this.workspace, id: args.version_id as string, events_offset: args.events_offset as number | undefined })); case "canvas_write": { - const target = this.target("canvas", args.name as string); - if (args.slug !== undefined) await this.requireHosted().links.check(target, args.slug as string); + const target = this.artifacts.target("canvas", args.name as string); + if (args.slug !== undefined) await this.artifacts.requireHosted().links.check(target, args.slug as string); const generation = await this.hosted?.links.begin(target); return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); } case "canvas_edit": { - const generation = await this.hosted?.links.begin(this.target("canvas", args.name as string)); + const generation = await this.hosted?.links.begin(this.artifacts.target("canvas", args.name as string)); return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }), generation); } case "canvas_restore": { const version = await this.library.version({ workspace: this.workspace, id: args.version_id as string }); - const generation = await this.hosted?.links.begin(this.target("canvas", version.name)); + const generation = await this.hosted?.links.begin(this.artifacts.target("canvas", version.name)); return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime }), generation); } case "canvas_typecheck": { @@ -97,7 +101,7 @@ export class CloudCanvasService { async preview(snapshot: Snapshot, compiled?: Compiled) { compiled ??= await this.compile(snapshot); - const base = { ...await this.linkDetails(this.target("canvas", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const base = { ...await this.artifacts.linkDetails(this.artifacts.target("canvas", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; if (!compiled.ok || !compiled.js) return { ...base, ok: false, _meta: undefined }; const { version, event } = await this.library.recordServe({ workspace: snapshot.workspace, name: snapshot.name, source: snapshot.source, @@ -111,19 +115,19 @@ export class CloudCanvasService { private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { const { source, server_source, state, ...summary } = mutation; - const target = this.target("canvas", mutation.name); + const target = this.artifacts.target("canvas", mutation.name); if (generation != null) { await this.hosted!.links.stage(target, generation, settings); settings = { ...await this.hosted!.links.draft(target), ...settings }; } const compiled = await this.compile(mutation); - const payload = { ...summary, ...await this.linkDetails(this.target("canvas", mutation.name)), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const payload = { ...summary, ...await this.artifacts.linkDetails(this.artifacts.target("canvas", mutation.name)), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; if (!compiled.ok) return { ...text(payload, true), structuredContent: payload }; // The draft is already committed. A delivery error must never look like a // rollback, and compilation must use that exact committed source snapshot. try { const { _meta, ...details } = await this.preview(mutation, compiled); - const target = this.target("canvas", mutation.name); + const target = this.artifacts.target("canvas", mutation.name); if (generation != null && details.ok && "canvas" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.canvas.versionId }); if (!link) { @@ -131,8 +135,8 @@ export class CloudCanvasService { return { ...text(superseded, true), structuredContent: superseded }; } } - const result = { ...payload, ...await this.linkDetails(target) }; - return { ...text(result, !details.ok), structuredContent: { ...result, preview: { ...details, ...await this.linkDetails(target) } }, ...(_meta ? { _meta } : {}) }; + const result = { ...payload, ...await this.artifacts.linkDetails(target) }; + return { ...text(result, !details.ok), structuredContent: { ...result, preview: { ...details, ...await this.artifacts.linkDetails(target) } }, ...(_meta ? { _meta } : {}) }; } catch (error) { const failed = { ...payload, ok: false, preview: { ok: false, error: error instanceof Error ? error.message : String(error) } }; return { ...text(failed, true), structuredContent: failed }; @@ -157,32 +161,16 @@ export class CloudCanvasService { return backend.request({ code: compiled.js, hash, request }); } - private requireHosted(): HostedArtifacts { - if (!this.hosted) throw new Error("Standalone artifacts are unavailable in this runtime"); - return this.hosted; - } - - private target(kind: "canvas" | "script", name: string): ArtifactTarget { - return { libraryKey: this.libraryKey, workspace: this.workspace, kind, name: name.trim().replace(kind === "canvas" ? /\.canvas\.tsx$/ : /\.script\.ts$/, "") }; - } - - private async linkDetails(target: ArtifactTarget) { - const link = await this.hosted?.links.find(target); - if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.origin}/${link.slug}` }; - const pending = await this.hosted?.links.draft(target); - return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {}; - } - private async artifactLink(args: Record): Promise { - const hosted = this.requireHosted(); + const hosted = this.artifacts.requireHosted(); if (args.kind !== "canvas" && args.kind !== "script") throw new Error("kind must be canvas or script"); - const target = this.target(args.kind, args.name as string); + const target = this.artifacts.target(args.kind, args.name as string); const existing = await hosted.links.find(target); if ((target.kind === "canvas" && existing?.version_id) || (target.kind === "script" && existing?.script_hash)) { // Access changes must work even while the current draft is invalid. Keep // serving the already validated revision when renaming or restricting it. await hosted.links.set({ ...target, slug: args.slug as string, access: args.access as "private" | "public" | undefined }); - return text({ ok: true, ...await this.linkDetails(target) }); + return text({ ok: true, ...await this.artifacts.linkDetails(target) }); } await hosted.links.check(target, args.slug as string); const generation = await hosted.links.begin(target); @@ -190,7 +178,7 @@ export class CloudCanvasService { if (target.kind === "script") { const active = await hosted.scripts.active({ workspace: this.workspace, name: target.name }); const link = await hosted.links.commit(target, generation, { ...settings, script_hash: active.hash }); - return link ? text({ ok: true, ...await this.linkDetails(target) }) : text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); + return link ? text({ ok: true, ...await this.artifacts.linkDetails(target) }) : text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); } const snapshot = await this.snapshot({ name: target.name }); const compiled = await this.compile(snapshot); @@ -200,115 +188,11 @@ export class CloudCanvasService { const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.canvas.versionId }); if (!link) return text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); } - return { ...text({ ...preview, ...await this.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; + return { ...text({ ...preview, ...await this.artifacts.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; } - async scriptReadSource(selection: { name?: string; version_id?: string }) { - const scripts = this.requireHosted().scripts; - if (selection.version_id) { - const version = await scripts.version({ workspace: this.workspace, id: selection.version_id }); - if (selection.name && version.name !== this.target("script", selection.name).name) throw new Error("Version does not belong to this script"); - return { ...version, path: `${this.workspace}/${version.name}.script.ts` }; - } - if (!selection.name) throw new Error("name or version_id is required"); - return scripts.readRange({ workspace: this.workspace, name: selection.name, end_line: Number.MAX_SAFE_INTEGER }); - } - - private async scriptTool(tool: string, args: Record): Promise { - const hosted = this.requireHosted(), scripts = hosted.scripts; - const name = args.name as string, input = { workspace: this.workspace, name }; - const offset = args.offset as number | undefined ?? 0; - switch (tool) { - case "script_guide": return { content: [{ type: "text", text: HOSTED_SCRIPT_GUIDE }] }; - case "script_list": { - const drafts = await scripts.listDrafts({ workspace: this.workspace, offset }); - const entries = await Promise.all(drafts.map(async draft => ({ ...draft, ...await this.linkDetails(this.target("script", draft.name)) }))); - return text({ scripts: entries, next_offset: entries.length === 100 ? offset + 100 : null }); - } - case "script_read": { - if (!args.version_id) return text(await scripts.readRange({ ...input, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); - const version = await this.scriptReadSource({ name, version_id: args.version_id as string }); - const lines = version.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; - const start = args.start_line as number | undefined ?? 1, end = args.end_line as number | undefined ?? start + 199; - if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || start > lines.length) throw new Error("invalid line range"); - const actualEnd = Math.min(end, lines.length); - return text({ ...version, source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null }); - } - case "script_history": { - const versions = await scripts.history({ workspace: this.workspace, name, offset }); - return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); - } - case "script_version": return text(await scripts.version({ workspace: this.workspace, id: args.version_id as string })); - case "script_logs": return text({ logs: await hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, this.target("script", name).name])).logs({ limit: args.limit as number | undefined }) }); - case "script_secrets": { - if (args.secrets !== undefined) { - if (!args.secrets || typeof args.secrets !== "object" || Array.isArray(args.secrets)) throw new Error("secrets must be an object"); - for (const [key, value] of Object.entries(args.secrets)) await scripts.setSecret({ ...input, key, value: value as string | null }); - } - return text({ ok: true, names: await scripts.secretNames(input) }); - } - case "script_run": { - const link = await hosted.links.find(this.target("script", name)); - if (!link?.script_hash) throw new Error("Script has no validated URL revision"); - const active = await scripts.active({ ...input, hash: link.script_hash }); - const incoming = nativeRequest(args.request as CanvasHttpRequest); - const request = new Request(new URL(new URL(incoming.url).pathname + new URL(incoming.url).search, hosted.origin), incoming); - const response = await hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, this.target("script", name).name])).request({ ...active, request }); - const envelope = await scriptResponse(response, incoming.method); - return { ...text({ status: envelope.status, ...(link ? { url: `${hosted.origin}/${link.slug}` } : {}) }), structuredContent: { response: envelope } }; - } - case "script_write": case "script_edit": case "script_restore": { - const target = this.target("script", name); - // Read pending intent only after taking this operation's generation. - // A concurrent explicit access change then either precedes this read or - // supersedes this generation; it cannot be undone by stale metadata. - const generation = await hosted.links.begin(target); - const settings: LinkUpdate = await hosted.links.draft(target); - if (tool === "script_write") { - const previous = await hosted.links.find(target); - settings.slug = args.slug as string | undefined ?? settings.slug ?? previous?.slug ?? target.name; - if (args.access !== undefined) settings.access = args.access as "private" | "public"; - } - if (settings.slug !== undefined) await hosted.links.check(target, settings.slug); - if (tool === "script_restore") { - const version = await scripts.version({ workspace: this.workspace, id: args.version_id as string }); - if (version.name !== target.name) throw new Error("Version does not belong to this script"); - } - const mutation = tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string }) - : tool === "script_edit" ? await scripts.editDraft({ ...input, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }) - : await scripts.restore({ workspace: this.workspace, id: args.version_id as string }); - await hosted.links.stage(target, generation, settings); - const compiled = await compileScriptSource(mutation.source); - if (compiled.ok && compiled.js) { - const secrets = await scripts.executionSecrets(input); - const backend = hosted.scriptBackends.getByName(JSON.stringify([this.libraryKey, this.workspace, target.name])); - const validation = await backend.validate({ code: compiled.js, hash: createHash("sha256").update(compiled.js).digest("hex"), secrets }); - if (validation.ok) { - try { - const activated = await scripts.activate({ ...input, source_hash: mutation.source_hash, code: compiled.js }); - const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash }); - if (!link) { - const { source, ...summary } = mutation; - const superseded = { ...summary, ...await this.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; - return { ...text(superseded, true), structuredContent: superseded }; - } - } catch (error) { - const { source, ...summary } = mutation; - const message = error instanceof Error ? error.message : "Script URL activation failed"; - const failed = { ...summary, ...await this.linkDetails(target), applied: true, ok: false, ...(message.includes("changed during validation") ? { superseded: true } : {}), error: message }; - return { ...text(failed, true), structuredContent: failed }; - } - } else { - compiled.ok = false; - compiled.diagnostics.push({ severity: "error", message: "Script module could not initialize" }); - } - } - const { source, ...summary } = mutation; - const payload = { ...summary, ...await this.linkDetails(target), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; - return { ...text(payload, !compiled.ok), structuredContent: payload }; - } - default: throw new Error("Unknown script tool"); - } + scriptReadSource(selection: { name?: string; version_id?: string }) { + return this.scripts.readSource(selection); } async gallery(all: boolean, offset = 0): Promise { @@ -347,7 +231,7 @@ export class CloudCanvasService { } for (const artifact of artifacts.values()) { artifact.kind ??= "canvas"; - Object.assign(artifact, await this.linkDetails({ libraryKey: this.libraryKey, workspace: artifact.workspace, name: artifact.name, kind: artifact.kind })); + Object.assign(artifact, await this.artifacts.linkDetails({ libraryKey: this.libraryKey, workspace: artifact.workspace, name: artifact.name, kind: artifact.kind })); } } return { ...(this.hosted ? { capabilities: { scripts: true, links: true } } : {}), workspace: this.workspace, artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: scriptsHaveMore || versions.length === 100 || drafts.length === 100 ? offset + 100 : null }; @@ -357,21 +241,3 @@ export class CloudCanvasService { function text(value: unknown, isError = false): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError }; } - -const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs - -Canvases and scripts coexist. Use canvas_write with an optional slug, or artifact_link({kind:"canvas",name,slug,access}), to give a canvas a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated. - -Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Unbundled third-party imports, relative modules and package.json dependency installation are not supported by script_write. The server accepts one self-contained module, at most 256 KiB of UTF-8 source. - -Use script_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) to set/delete values, or omit secrets to list names. Values never appear in source history or management reads. Use env.secrets.TOKEN inside the handler. Avoid returning secrets in HTTP responses. Initialize database tables with create table if not exists; source edits/restores preserve the database. - -script_write and script_edit save source before validation. Inspect applied, ok, and diagnostics: failed drafts remain editable, while the URL keeps serving the last validated code. No separate publish operation is needed. script_read reads draft or historical source, script_history lists revisions, script_version reads one, and script_restore restores and validates a prior revision. script_list, script_read, and gallery views never execute handlers. - -script_run({name,request:{path:"/chosen-slug?key=value",method:"POST",headers:[["content-type","text/plain"]],body:"aGVsbG8="}}) explicitly invokes the last validated handler. The request path and query are passed exactly as provided, using the deployment origin; include the slug in path to reproduce a direct URL request. Body is base64. MCP requests and responses are limited to 256 KiB; use direct URLs for streaming or larger responses. script_logs reads bounded recent execution logs and errors. Direct URLs preserve incoming paths, methods and body bytes and return the handler response. Management cookies and Cloudflare Access credentials are stripped; private routes also strip Authorization. Public Authorization is passed to the handler. Script HTML gets an opaque-origin sandbox and responses cannot set cookies. Public handlers can implement their own authentication, including provider signature checks. Cloudflare Access policies protecting the entire hostname need a bypass for intended public paths. No schedules or separate publishing workflow are provided. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction. - -Third-party dependencies -Keep the editable source, package.json and lockfile locally. Install pinned packages with bun add --exact @. Typecheck your original TypeScript before bundling. Bundle locally with Bun.build({entrypoints:["handler.ts"],target:"browser",format:"esm",external:["node:*","cloudflare:*"],banner:"// @ts-nocheck",minify:true}). Check build.success, then read build.outputs[0].text() and pass that entire string as script_write.contents. The generated-JavaScript banner avoids implicit-any errors after type erasure; it is only for the generated bundle, not handwritten source. Retain your original sources and rebuild after edits rather than editing bundled internals. Workers cannot execute arbitrary Node processes/native addons; use Workers-compatible packages. If the bundle exceeds 256 KiB, reduce the dependency footprint; splitting it into relative imports is not supported. - -After writing, check ok/diagnostics and the returned URL. Creation loads the module for validation but does not invoke the handler. Explicit script_run or an HTTP request invokes it and may mutate state or call external services. Inspect the HTTP status/body and script_logs before reporting the handler working. script_guide and canvas_guide describe the hosted contracts; local Bun stdio does not expose script tools. -`; diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 53d6cd3..39dd83f 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -1,5 +1,5 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { MCP_TOOLS } from "../src/mcpTools"; +import { MCP_TOOLS } from "../src/mcp/tools"; // Share the local tool contract. A hosted service cannot open a terminal pane. export const CLOUD_MCP_TOOLS: Tool[] = JSON.parse(JSON.stringify(MCP_TOOLS)); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b73b94b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Source map + +Canvas is one package with two execution paths. `canvas web`, filesystem commands, +and stdio MCP use Bun with local canvas files and a history database. The hosted +application uses Workers and Durable Objects; it runs on Cloudflare or locally +through celld (`canvas server`). Running the Worker application locally does not +turn it into the filesystem/stdio implementation. + +| Location | Responsibility | +| --- | --- | +| `src/cli.ts` | Public CLI entrypoint, including the Herdr plugin's list/open actions | +| `src/service.ts`, `canvasFile.ts`, `history.ts`, `compile.ts`, `typecheck.ts` | Bun filesystem canvas operations | +| `src/local/` | Local process lifecycle: `preview-daemon.ts` reuses/starts a Bun preview server; `server-daemon.ts` manages the celld server through launchd/systemd; `server.ts` launches it; `celld-runtime.ts` supplies the pinned executable | +| `src/mcp/` | Shared MCP tool schemas (`tools.ts`), canvas guide (`guide.ts`), and app contract (`app-contract.ts`); Bun tool dispatch (`local-tools.ts`), stdio framing/transport (`protocol.ts`, `stdio.ts`), and app HTML/local preview assembly (`app.ts`) | +| `src/sdk/` | Public canvas authoring API, components, hooks, and native server helpers | +| `src/runtime/` | Browser entrypoints and bridges for Herdr, MCP Apps, and hosted canvases | +| `src/gallery/`, `src/auth/` | Gallery and sign-in UI, plus their asset assembly/serving helpers | +| `src/httpTypes.ts`, `historyTypes.ts`, `diagnostics.ts`, `sandbox.ts` | Shared request/history contracts and source validation used by both execution paths | +| `cloudflare/` | Worker application, HTTP MCP, authentication, storage, compilation and execution; also packaged for celld | +| `scripts/` | Build, packaging, development and deployment utilities | +| `e2e/` | Browser, Herdr and packaged-runtime integration harnesses; unit/service tests stay beside their source | + +## Hosted services + +`cloudflare/worker.ts` connects authenticated requests to the application. +`mcp.ts` owns the HTTP MCP transport and `tool-contract.ts` specializes the shared +schemas for hosted capabilities. `service.ts` coordinates canvas operations, +canvas activation, artifact links, and the combined gallery. `script-service.ts` +owns script reads, mutations, execution, secrets and logs; `script-guide.ts` +contains its MCP authoring guidance. `artifact-service.ts` supplies the shared +library/workspace identity and URL metadata used by both services. + +Storage and execution remain separate: `library.ts` and `scripts.ts` keep source +and revisions; `links.ts` owns slugs and active revision pointers; `backend.ts` +and `script-backend.ts` execute code. Code edits retain the validation and atomic +activation sequence before switching a working URL. + +## Herdr integration + +`herdr-plugin.toml` declares the pane and actions. An action specifies the command +Herdr runs; it does not need a separate source directory. Both list and open use +`src/cli.ts`. Open accepts an explicit canvas name or falls back to +`HERDR_CANVAS_NAME`; `--version` selects archived source without that fallback. +`src/herdr.ts` is the Herdr client, `src/open.ts` coordinates canvas opening, and +`src/viewer.ts` owns the pane lifecycle. + +## Packaging + +`package.json` lists shipped source files explicitly. When relocating a runtime +module, update that list and build-time references as well as imports. The clean +tarball integration test exercises the installed CLI, gallery, compiler and MCP +without a checkout, so it detects missing package files. Generated Worker assets +live in `dist/`; source imports under `cloudflare/` require those build outputs. diff --git a/docs/releasing.md b/docs/releasing.md index 75372f8..0b4b33d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,7 +59,7 @@ The user enables it explicitly through the [daemon commands](daemon.md). ## celld upgrades -The managed runtime is pinned in `src/celldRuntime.ts`. For an upgrade, obtain the +The managed runtime is pinned in `src/local/celld-runtime.ts`. For an upgrade, obtain the archive SHA-256 values from the upstream release, verify each platform archive, and compute the decompressed executable hashes before updating the manifest. Do not select `latest` dynamically. The cache is keyed by celld version and target; diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index e9bbc6d..ff808b6 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -3,7 +3,7 @@ import { readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium, type Browser, type FrameLocator, type Page } from "playwright"; -import { canvasAppHtml, canvasAppResult, type CanvasAppPayload } from "../src/mcpApp"; +import { canvasAppHtml, canvasAppResult, type CanvasAppPayload } from "../src/mcp/app"; import { CanvasService } from "../src/service"; import { tempDir, writeCanvas } from "../src/test/fixtures"; diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 2d22dd4..7168696 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -21,4 +21,4 @@ command = ["bun", "run", "src/cli.ts", "list"] id = "open" title = "Open canvas" contexts = ["workspace"] -command = ["bun", "run", "src/actions/open.ts"] +command = ["bun", "run", "src/cli.ts", "open"] diff --git a/package.json b/package.json index e550dc5..29b7918 100644 --- a/package.json +++ b/package.json @@ -23,23 +23,49 @@ "canvas": "src/cli.ts" }, "files": [ - "docs/daemon.md", - "docs/releasing.md", "dist/celld/**", "dist/cloudflare/**", "dist/worker-app/**", - "src/actions/open.ts", + "docs/architecture.md", + "docs/daemon.md", + "docs/releasing.md", + "docs/scripts.md", + "src/args.ts", "src/auth/client-api.ts", "src/auth/client.tsx", "src/auth/server.ts", + "src/canvasFile.ts", + "src/cli.ts", + "src/compile.ts", + "src/diagnostics.ts", "src/gallery/client.tsx", + "src/gallery/hosted.tsx", "src/gallery/server.ts", "src/gallery/types.ts", - "src/runtime/herdr-canvas-entry.d.ts", + "src/herdr.ts", + "src/history.ts", + "src/historyTypes.ts", + "src/html.ts", + "src/httpTypes.ts", + "src/local/celld-runtime.ts", + "src/local/preview-daemon.ts", + "src/local/server-daemon.ts", + "src/local/server.ts", + "src/mcp/app-contract.ts", + "src/mcp/app.ts", + "src/mcp/guide.ts", + "src/mcp/local-tools.ts", + "src/mcp/protocol.ts", + "src/mcp/stdio.ts", + "src/mcp/tools.ts", + "src/open.ts", + "src/paths.ts", "src/runtime/cloud-runtime.ts", "src/runtime/gallery-request.ts", + "src/runtime/herdr-canvas-entry.d.ts", "src/runtime/mcp-app.ts", "src/runtime/wrapper.tsx", + "src/sandbox.ts", "src/sdk/charts.tsx", "src/sdk/forms.tsx", "src/sdk/hooks.ts", @@ -47,36 +73,10 @@ "src/sdk/server.ts", "src/sdk/tokens.ts", "src/sdk/ui.tsx", - "src/args.ts", - "src/canvasFile.ts", - "src/canvasGuide.ts", - "src/celldDaemon.ts", - "src/celldRuntime.ts", - "src/celldServer.ts", - "src/cli.ts", - "src/compile.ts", - "src/daemon.ts", - "src/diagnostics.ts", - "src/herdr.ts", - "src/history.ts", - "src/historyTypes.ts", - "src/html.ts", - "src/httpTypes.ts", - "src/mcp.ts", - "src/mcpApp.ts", - "src/mcpAppContract.ts", - "src/mcpProtocol.ts", - "src/mcpServer.ts", - "src/mcpTools.ts", - "src/open.ts", - "src/paths.ts", - "src/sandbox.ts", "src/serve.ts", "src/service.ts", "src/typecheck.ts", - "src/viewer.ts", - "src/gallery/hosted.tsx", - "docs/scripts.md" + "src/viewer.ts" ], "publishConfig": { "access": "public" diff --git a/scripts/prepare-cloudflare.ts b/scripts/prepare-cloudflare.ts index 4825412..035b679 100644 --- a/scripts/prepare-cloudflare.ts +++ b/scripts/prepare-cloudflare.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; -import { canvasAppHtml } from "../src/mcpApp"; +import { canvasAppHtml } from "../src/mcp/app"; import { galleryBundle, galleryHtml } from "../src/gallery/server"; import { authBundle, authPageHtml } from "../src/auth/server"; import Ajv from "ajv"; diff --git a/src/actions/open.ts b/src/actions/open.ts deleted file mode 100644 index 478c1f4..0000000 --- a/src/actions/open.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bun - -import { canvasesDirFrom } from "../canvasFile"; -import { createHerdrClient } from "../herdr"; -import { CanvasService } from "../service"; - -const name = process.argv[2] ?? process.env.HERDR_CANVAS_NAME; -if (!name) { - console.error("missing canvas name"); - process.exit(1); -} - -const service = new CanvasService({ - canvasesDir: canvasesDirFrom(process.cwd()), - herdr: createHerdrClient(), -}); -const result = await service.open(name); -console.log(JSON.stringify(result, null, 2)); -if (!result.ok) { - process.exit(1); -} diff --git a/src/cli.test.ts b/src/cli.test.ts index 1cf263a..11910d1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -6,7 +6,7 @@ import { PLUGIN_ROOT } from "./paths"; import { VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; import { CanvasHistory } from "./history"; import { createCanvasServer } from "./serve"; -import { handleMcpRequest } from "./mcp"; +import { handleMcpRequest } from "./mcp/local-tools"; import { CanvasService } from "./service"; import { parseArgs } from "./args"; import { runServerCommand } from "./cli"; @@ -86,6 +86,29 @@ test("CLI compile bundles a valid canvas", async () => { expect(payload.bytes).toBeGreaterThan(100); }); +test("CLI open uses the plugin canvas name fallback and prefers an explicit name", async () => { + const dir = tempDir(); + const fallbackPath = writeCanvas(dir, "fallback", VALID_CANVAS); + const explicitPath = writeCanvas(dir, "explicit", VALID_CANVAS); + const dbPath = join(dir, "history.sqlite"); + const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); + const common = ["--dir", dir, "--history-db", dbPath, "--no-open"]; + const env = { HERDR_CANVAS_NAME: "fallback", HERDR_CANVAS_SERVER_URL: server.url }; + try { + const fallback = await runCli(["open", ...common], env); + expect(fallback.exitCode).toBe(0); + expect(JSON.parse(fallback.stdout)).toMatchObject({ ok: true, path: fallbackPath }); + const explicit = await runCli(["open", "explicit", ...common], env); + expect(explicit.exitCode).toBe(0); + expect(JSON.parse(explicit.stdout)).toMatchObject({ ok: true, path: explicitPath }); + for (const versionFlags of [["--version"], ["--version="]]) { + const invalidVersion = await runCli(["open", ...versionFlags, ...common], env); + expect(invalidVersion.exitCode).toBe(1); + expect(invalidVersion.stderr).toContain("provide a canvas name or --version ID"); + } + } finally { server.stop(); } +}, { timeout: 30_000 }); + test("CLI history, show, open --version and restore work for an archived canvas whose file was deleted", async () => { const dir = tempDir(); const path = writeCanvas(dir, "overview", VALID_CANVAS); @@ -104,7 +127,7 @@ test("CLI history, show, open --version and restore work for an archived canvas const shown = await runCli(["show", version.id, ...common]); expect(JSON.parse(shown.stdout).source).toBe(VALID_CANVAS); expect((await runCli(["show", version.id, "--source", ...common])).stdout).toBe(VALID_CANVAS); - const reopened = await runCli(["open", "--version", version.id, "--event", eventId, ...common, "--no-open"], { HERDR_CANVAS_SERVER_URL: server.url }); + const reopened = await runCli(["open", "--version", version.id, "--event", eventId, ...common, "--no-open"], { HERDR_CANVAS_SERVER_URL: server.url, HERDR_CANVAS_NAME: "unrelated" }); expect(reopened.exitCode).toBe(0); const url = JSON.parse(reopened.stdout).url; expect(url).toBe(`${server.url}/v/${version.id}?event=${eventId}`); diff --git a/src/cli.ts b/src/cli.ts index 45059d4..8b60985 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,11 +5,11 @@ import { join, resolve } from "node:path"; import { flagBoolean, flagString, parseArgs, type ParsedArgs } from "./args"; import { canvasesDirFrom } from "./canvasFile"; -import { clearServerReady, ServerDaemonManager, writeServerReady } from "./celldDaemon"; -import { runCelldServer } from "./celldServer"; -import { writeDaemonState, daemonStatePath } from "./daemon"; +import { clearServerReady, ServerDaemonManager, writeServerReady } from "./local/server-daemon"; +import { runCelldServer } from "./local/server"; +import { writeDaemonState, daemonStatePath } from "./local/preview-daemon"; import { createHerdrClient, type PanePlacement } from "./herdr"; -import { runMcpServer } from "./mcpServer"; +import { runMcpServer } from "./mcp/stdio"; import { createCanvasServer } from "./serve"; import { CanvasService } from "./service"; import { historyPath } from "./history"; @@ -200,7 +200,7 @@ async function main(): Promise { } if (args.command === "open") { - const name = args.positionals[0]; + const name = args.positionals[0] ?? (args.flags.version === undefined ? process.env.HERDR_CANVAS_NAME : undefined); const versionId = flagString(args.flags, "version"); if (Boolean(name) === Boolean(versionId)) throw new Error("provide a canvas name or --version ID, but not both"); const result = await service.open(name ?? "", { diff --git a/src/celldRuntime.test.ts b/src/local/celld-runtime.test.ts similarity index 99% rename from src/celldRuntime.test.ts rename to src/local/celld-runtime.test.ts index c8d8de5..75a9cc0 100644 --- a/src/celldRuntime.test.ts +++ b/src/local/celld-runtime.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; -import { canvasDataRoot, celldArtifact, ensureCelldRuntime } from "./celldRuntime"; +import { canvasDataRoot, celldArtifact, ensureCelldRuntime } from "./celld-runtime"; const directories: string[] = []; afterEach(async () => { for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); }); diff --git a/src/celldRuntime.ts b/src/local/celld-runtime.ts similarity index 100% rename from src/celldRuntime.ts rename to src/local/celld-runtime.ts diff --git a/src/daemon.test.ts b/src/local/preview-daemon.test.ts similarity index 95% rename from src/daemon.test.ts rename to src/local/preview-daemon.test.ts index bcc4e8c..640bdaa 100644 --- a/src/daemon.test.ts +++ b/src/local/preview-daemon.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; import { join } from "node:path"; -import { daemonStatePath, ensureCanvasServer, writeDaemonState } from "./daemon"; -import { tempDir } from "./test/fixtures"; -import { historyPath } from "./history"; +import { daemonStatePath, ensureCanvasServer, writeDaemonState } from "./preview-daemon"; +import { tempDir } from "../test/fixtures"; +import { historyPath } from "../history"; test("ensureCanvasServer reuses a healthy daemon", async () => { const dir = tempDir(); diff --git a/src/daemon.ts b/src/local/preview-daemon.ts similarity index 96% rename from src/daemon.ts rename to src/local/preview-daemon.ts index 5e13a5a..dd11e4a 100644 --- a/src/daemon.ts +++ b/src/local/preview-daemon.ts @@ -4,9 +4,9 @@ import { spawn as spawnProcess } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { CLI_ENTRY } from "./paths"; -import { createCanvasServer, type CanvasServer } from "./serve"; -import { historyPath } from "./history"; +import { CLI_ENTRY } from "../paths"; +import { createCanvasServer, type CanvasServer } from "../serve"; +import { historyPath } from "../history"; export type DaemonState = { pid: number; diff --git a/src/celldDaemon.test.ts b/src/local/server-daemon.test.ts similarity index 99% rename from src/celldDaemon.test.ts rename to src/local/server-daemon.test.ts index b34f59d..b923091 100644 --- a/src/celldDaemon.test.ts +++ b/src/local/server-daemon.test.ts @@ -11,8 +11,8 @@ import { writeServerReady, type CommandResult, type RunCommand, -} from "./celldDaemon"; -import { tempDir } from "./test/fixtures"; +} from "./server-daemon"; +import { tempDir } from "../test/fixtures"; const ok = (stdout = ""): CommandResult => ({ exitCode: 0, stdout, stderr: "" }); const missing = (): CommandResult => ({ exitCode: 1, stdout: "", stderr: "not found" }); diff --git a/src/celldDaemon.ts b/src/local/server-daemon.ts similarity index 99% rename from src/celldDaemon.ts rename to src/local/server-daemon.ts index 99728e9..b80a23a 100644 --- a/src/celldDaemon.ts +++ b/src/local/server-daemon.ts @@ -4,8 +4,8 @@ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/prom import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { canvasDataRoot, ensureCelldRuntime } from "./celldRuntime"; -import { CLI_ENTRY, PLUGIN_ROOT } from "./paths"; +import { canvasDataRoot, ensureCelldRuntime } from "./celld-runtime"; +import { CLI_ENTRY, PLUGIN_ROOT } from "../paths"; export const SERVER_PORT = 4786; export const LAUNCHD_LABEL = "com.sidequery.canvas.server"; diff --git a/src/celldServer.ts b/src/local/server.ts similarity index 97% rename from src/celldServer.ts rename to src/local/server.ts index 4ede175..afe6a75 100644 --- a/src/celldServer.ts +++ b/src/local/server.ts @@ -2,8 +2,8 @@ import { Database } from "bun:sqlite"; import { createRequire } from "node:module"; import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { canvasDataRoot, ensureCelldRuntime } from "./celldRuntime"; -import { PLUGIN_ROOT } from "./paths"; +import { canvasDataRoot, ensureCelldRuntime } from "./celld-runtime"; +import { PLUGIN_ROOT } from "../paths"; export async function runCelldServer(options: { port?: number; stateDir?: string; signal: AbortSignal; onReady?: (url: string) => void | Promise }): Promise { const port = options.port ?? 4786; diff --git a/src/mcpAppContract.ts b/src/mcp/app-contract.ts similarity index 100% rename from src/mcpAppContract.ts rename to src/mcp/app-contract.ts diff --git a/src/mcpApp.test.ts b/src/mcp/app.test.ts similarity index 95% rename from src/mcpApp.test.ts rename to src/mcp/app.test.ts index 0adb190..5d89acb 100644 --- a/src/mcpApp.test.ts +++ b/src/mcp/app.test.ts @@ -3,11 +3,11 @@ import { join } from "node:path"; import { readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { CANVAS_APP_URI, CANVAS_APP_MIME, canvasAppResult } from "./mcpApp"; -import { handleMcpRequest } from "./mcp"; -import { CanvasHistory } from "./history"; -import { CanvasService } from "./service"; -import { tempDir, VALID_CANVAS, writeCanvas } from "./test/fixtures"; +import { CANVAS_APP_URI, CANVAS_APP_MIME, canvasAppResult } from "./app"; +import { handleMcpRequest } from "./local-tools"; +import { CanvasHistory } from "../history"; +import { CanvasService } from "../service"; +import { tempDir, VALID_CANVAS, writeCanvas } from "../test/fixtures"; function fixture() { const dir = tempDir(); @@ -94,7 +94,7 @@ test("official MCP client can initialize, discover UI, create and show over stan const client = new Client({ name: "canvas-test", version: "1" }); const transport = new StdioClientTransport({ command: process.execPath, - args: ["run", join(import.meta.dir, "cli.ts"), "mcp", "--dir", dir, "--history-db", join(dir, "history.sqlite")], + args: ["run", join(import.meta.dir, "../cli.ts"), "mcp", "--dir", dir, "--history-db", join(dir, "history.sqlite")], stderr: "pipe", }); try { diff --git a/src/mcpApp.ts b/src/mcp/app.ts similarity index 94% rename from src/mcpApp.ts rename to src/mcp/app.ts index 72abd54..e519a84 100644 --- a/src/mcpApp.ts +++ b/src/mcp/app.ts @@ -1,14 +1,14 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { canvasIdFromFile, assertRegularCanvas, ensureCanvasFileName } from "./canvasFile"; -import { compileCanvas } from "./compile"; -import { formatCanvasCheck } from "./diagnostics"; -import { CanvasHistory, historyPath, runtimeIdentity } from "./history"; -import { PLUGIN_ROOT } from "./paths"; -import type { CanvasService } from "./service"; +import { canvasIdFromFile, assertRegularCanvas, ensureCanvasFileName } from "../canvasFile"; +import { compileCanvas } from "../compile"; +import { formatCanvasCheck } from "../diagnostics"; +import { CanvasHistory, historyPath, runtimeIdentity } from "../history"; +import { PLUGIN_ROOT } from "../paths"; +import type { CanvasService } from "../service"; -export * from "./mcpAppContract"; -import type { CanvasAppPayload } from "./mcpAppContract"; +export * from "./app-contract"; +import type { CanvasAppPayload } from "./app-contract"; let shell: Promise | undefined; export function canvasAppHtml(): Promise { diff --git a/src/canvasGuide.ts b/src/mcp/guide.ts similarity index 100% rename from src/canvasGuide.ts rename to src/mcp/guide.ts diff --git a/src/mcp.test.ts b/src/mcp/local-tools.test.ts similarity index 94% rename from src/mcp.test.ts rename to src/mcp/local-tools.test.ts index a6d773a..df8a07e 100644 --- a/src/mcp.test.ts +++ b/src/mcp/local-tools.test.ts @@ -1,14 +1,14 @@ import { expect, test } from "bun:test"; -import { handleMcpRequest } from "./mcp"; -import { encodeMessage, parseMessages } from "./mcpProtocol"; -import { CanvasService } from "./service"; -import { VALID_CANVAS, tempDir } from "./test/fixtures"; +import { handleMcpRequest } from "./local-tools"; +import { encodeMessage, parseMessages } from "./protocol"; +import { CanvasService } from "../service"; +import { VALID_CANVAS, tempDir } from "../test/fixtures"; import { join } from "node:path"; import { readFileSync } from "node:fs"; -import { CanvasHistory } from "./history"; -import { CANVAS_GUIDE_EXPORTS } from "./canvasGuide"; -import * as sdk from "./sdk"; +import { CanvasHistory } from "../history"; +import { CANVAS_GUIDE_EXPORTS } from "./guide"; +import * as sdk from "../sdk"; test("guide documents exactly the installed SDK runtime exports", () => { expect([...CANVAS_GUIDE_EXPORTS].sort()).toEqual(Object.keys(sdk).sort()); diff --git a/src/mcp.ts b/src/mcp/local-tools.ts similarity index 95% rename from src/mcp.ts rename to src/mcp/local-tools.ts index 0325abf..5e83e34 100644 --- a/src/mcp.ts +++ b/src/mcp/local-tools.ts @@ -1,13 +1,13 @@ -import { CanvasService, type CanvasEdit, type ReadOptions } from "./service"; -import { canvasIdFromFile } from "./canvasFile"; -import { canvasGuideResult } from "./canvasGuide"; -import type { JsonRpcRequest, JsonRpcResponse } from "./mcpProtocol"; -import { CANVAS_APP_URI, CANVAS_RESOURCE, canvasAppHtml, canvasAppResult } from "./mcpApp"; +import { CanvasService, type CanvasEdit, type ReadOptions } from "../service"; +import { canvasIdFromFile } from "../canvasFile"; +import { canvasGuideResult } from "./guide"; +import type { JsonRpcRequest, JsonRpcResponse } from "./protocol"; +import { CANVAS_APP_URI, CANVAS_RESOURCE, canvasAppHtml, canvasAppResult } from "./app"; const PROTOCOL_VERSION = "2025-06-18"; -export { MCP_TOOLS } from "./mcpTools"; -import { MCP_TOOLS } from "./mcpTools"; +export { MCP_TOOLS } from "./tools"; +import { MCP_TOOLS } from "./tools"; export async function handleMcpRequest( request: JsonRpcRequest, diff --git a/src/mcpProtocol.ts b/src/mcp/protocol.ts similarity index 100% rename from src/mcpProtocol.ts rename to src/mcp/protocol.ts diff --git a/src/mcpServer.ts b/src/mcp/stdio.ts similarity index 88% rename from src/mcpServer.ts rename to src/mcp/stdio.ts index a889c95..4234cf7 100644 --- a/src/mcpServer.ts +++ b/src/mcp/stdio.ts @@ -1,6 +1,6 @@ -import { encodeMessage, parseMessages, type JsonRpcRequest } from "./mcpProtocol"; -import { handleMcpRequest } from "./mcp"; -import { CanvasService } from "./service"; +import { encodeMessage, parseMessages, type JsonRpcRequest } from "./protocol"; +import { handleMcpRequest } from "./local-tools"; +import { CanvasService } from "../service"; export async function runMcpServer(service: CanvasService): Promise { let buffer: Buffer = Buffer.alloc(0); diff --git a/src/mcpTools.ts b/src/mcp/tools.ts similarity index 99% rename from src/mcpTools.ts rename to src/mcp/tools.ts index 66d3acd..ed19fd3 100644 --- a/src/mcpTools.ts +++ b/src/mcp/tools.ts @@ -1,4 +1,4 @@ -import { CANVAS_APP_META } from "./mcpAppContract"; +import { CANVAS_APP_META } from "./app-contract"; export const MCP_TOOLS = [ { diff --git a/src/open.ts b/src/open.ts index d70eb7c..1ea36ff 100644 --- a/src/open.ts +++ b/src/open.ts @@ -1,6 +1,6 @@ import { canvasIdFromFile } from "./canvasFile"; import { compileCanvas } from "./compile"; -import { ensureCanvasServer } from "./daemon"; +import { ensureCanvasServer } from "./local/preview-daemon"; import { formatCanvasCheck } from "./diagnostics"; import { canvasPaneOpenArgs, diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index d641ca8..141ddcb 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -1,5 +1,5 @@ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import type { CanvasAppPayload } from "../mcpApp"; +import type { CanvasAppPayload } from "../mcp/app"; import type { CanvasAction, HostBridge } from "../sdk/hooks"; import type { CanvasHttpRequest, CanvasHttpResponse } from "../sdk/server";