From 3dafc461e3cd9e5f633f20436c8b3c78e58d556d Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 2 Oct 2026 16:19:50 -0700 Subject: [PATCH] Add complete portable project archives --- README.md | 1 + cloudflare/http.ts | 14 ++++++ cloudflare/library.ts | 17 ++++++++ cloudflare/managed-service.ts | 13 ++++-- cloudflare/script-service.ts | 18 +++++--- cloudflare/scripts.ts | 11 +++++ cloudflare/service.ts | 14 ++++++ cloudflare/tool-contract.ts | 6 +++ cloudflare/worker.test.ts | 68 +++++++++++++++++++++++++++++ cloudflare/worker.ts | 19 +++++--- docs/project-portability.md | 23 ++++++++++ e2e/gallery-move.test.ts | 11 +++++ e2e/gallery-navigation.test.ts | 2 +- e2e/gallery-transfer.test.ts | 39 +++++++++++++++++ package.json | 7 ++- scripts/package.integration.test.ts | 4 ++ src/cli.test.ts | 16 +++++++ src/cli.ts | 28 +++++++++++- src/gallery/client.tsx | 24 +++++++--- src/gallery/move.tsx | 4 +- src/gallery/project-transfer.tsx | 48 ++++++++++++++++++++ src/history.ts | 10 +++-- src/localProject.ts | 17 ++++++++ src/mcp/guide.ts | 2 +- src/mcp/local-tools.ts | 7 +++ src/mcp/tools.ts | 13 ++++++ src/project-archive-contract.ts | 25 +++++++++++ src/project-archive.test.ts | 65 +++++++++++++++++++++++++++ src/project-archive.ts | 24 ++++++++++ src/serve.ts | 21 +++++++-- src/service.ts | 65 ++++++++++++++++++++++++--- 31 files changed, 599 insertions(+), 37 deletions(-) create mode 100644 docs/project-portability.md create mode 100644 e2e/gallery-transfer.test.ts create mode 100644 src/gallery/project-transfer.tsx create mode 100644 src/project-archive-contract.ts create mode 100644 src/project-archive.test.ts create mode 100644 src/project-archive.ts diff --git a/README.md b/README.md index 4d3df57..4e8fa2e 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ inline views, targeted edits, history, and compatibility with older Canvas sourc - [Deploy to Cloudflare](docs/cloudflare.md) - [Deploy celld on your own infrastructure](docs/celld-deployment.md) - [Scripts, URLs, dependencies, remix, and schedules](docs/scripts.md) +- [Complete project export and import](docs/project-portability.md) - [App backends and SQLite](docs/cloudflare.md#native-artifact-servers-and-storage) - [File storage](docs/files.md) - [Client-side routing](docs/routing.md) diff --git a/cloudflare/http.ts b/cloudflare/http.ts index 05c8e8e..775612f 100644 --- a/cloudflare/http.ts +++ b/cloudflare/http.ts @@ -1,3 +1,17 @@ +import { PROJECT_IMPORT_REQUEST_BYTES } from "../src/project-archive-contract"; + +/** Only archive imports may exceed the ordinary 1 MiB management request limit. */ +export async function readToolBody(request: Request, mcp = false): Promise { + const text = await readRequestText(request, PROJECT_IMPORT_REQUEST_BYTES); + const oversized = new TextEncoder().encode(text).byteLength > 1024 * 1024; + let value: { name?: string; method?: string; params?: { name?: string } } | null; + try { value = JSON.parse(text); } + catch (error) { if (oversized) throw new RangeError("Request exceeds 1 MiB"); throw error; } + const name = mcp ? value?.method === "tools/call" ? value.params?.name : undefined : value?.name; + if (oversized && name !== "artifact_import" && name !== "script_import") throw new RangeError("Request exceeds 1 MiB"); + return value; +} + export async function readRequestText(request: Request, maxBytes: number): Promise { if (!request.body) return ""; const reader = request.body.getReader(); diff --git a/cloudflare/library.ts b/cloudflare/library.ts index 36190e9..d37ff23 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -239,6 +239,23 @@ export class ArtifactLibrary extends DurableObject { }); } + importDraft(input: { workspace: string; name: string; source: string; server_source: string | null; project: ArtifactProject; runtime: string }) { + const workspace = workspaceName(input.workspace), name = artifactName(input.name), runtime = runtimeName(input.runtime); + const source = sourceText(input.source), server_source = input.server_source === null ? null : sourceText(input.server_source); + const project = normalizeProject(input.project); + return this.state.storage.transactionSync(() => { + if (first(this.sql.exec("select name from drafts where workspace=? and name=? union all select name from artifacts where workspace=? and name=?", workspace, name, workspace, name))) throw new Error("Destination artifact already exists; choose a new name"); + const timestamp = now(); + this.sql.exec("insert into drafts(workspace,name,source,server_source,source_hash,project,state,created_at,updated_at) values(?,?,?,?,?,?,'{}',?,?)", workspace, name, source, server_source, sha256(source), this.projectStorage.encode(project), timestamp, timestamp); + const version = this.capture(workspace, name, source, server_source, project, runtime, "import", null, true); + return { ok: true, imported: true, workspace, name, path: sourcePath(workspace, name), source, server_source, project, source_hash: sha256(source), revision_token: projectRevision(source, server_source, project), state: {}, versionId: version.id, revision: version.revision }; + }); + } + draftRevision(input: { workspace: string; name: string }): string { + const draft = this.draft(workspaceName(input.workspace), artifactName(input.name)); + return projectRevision(draft.source, draft.server_source, this.projectStorage.read(draft.project)); + } + remix(input: { workspace: string; name?: string; version_id?: string; new_name: string; runtime: string }) { const workspace = workspaceName(input.workspace), name = artifactName(input.new_name), runtime = runtimeName(input.runtime); if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index 05dd893..9357da9 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -8,6 +8,7 @@ import type { ArtifactHttpRequest } from "../src/httpTypes"; import type { ArtifactFileRequest } from "../src/sdk/files"; import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { CloudArtifactService } from "./service"; +import { parseProjectArchive } from "../src/project-archive"; const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }); type Selection = { name?: string; version_id?: string; event_id?: string }; @@ -54,6 +55,11 @@ export class ManagedArtifactService { return text({ [history ? "versions" : kind === "script" ? "scripts" : "artifacts"]: rows, next_offset: rows.length === 100 ? offset + 100 : null }); } const selection = this.selection(kind, { name: args.name as string | undefined, version_id: args.version_id as string | undefined }); + if (name.endsWith("_import")) { + parseProjectArchive(args.archive, kind); + const target = await this.links.admit(this.selection(kind, { name: args.new_name as string }), true); + return this.physical(target).callTool(name, args); + } const target = name.endsWith("_remix") ? await this.links.admitRemix(selection, args.new_name as string) : await this.links.admit(selection, name === "artifact_write" || name === "script_write"); @@ -102,10 +108,9 @@ export class ManagedArtifactService { Object.assign(item, await this.linkDetails(item.kind!, item.workspace, item.name)); if (item.working) { const target = await this.links.admit({ libraryKey: this.libraryKey, workspace: item.workspace, kind: item.kind!, name: item.name }); - const snapshot = item.kind === "script" - ? await this.env.SCRIPTS.getByName(target.libraryKey).readRange({ workspace: item.workspace, name: item.name, end_line: 1 }) - : await this.env.LIBRARIES.getByName(target.libraryKey).preview({ workspace: item.workspace, name: item.name }); - item.draftRevision = snapshot.revision_token; + item.draftRevision = item.kind === "script" + ? await this.env.SCRIPTS.getByName(target.libraryKey).draftRevision({ workspace: item.workspace, name: item.name }) + : await this.env.LIBRARIES.getByName(target.libraryKey).draftRevision({ workspace: item.workspace, name: item.name }); } } return { capabilities: { scripts: true, links: true, moves: true }, workspace: this.workspace, diff --git a/cloudflare/script-service.ts b/cloudflare/script-service.ts index a61dbbc..a9b1ca9 100644 --- a/cloudflare/script-service.ts +++ b/cloudflare/script-service.ts @@ -9,6 +9,7 @@ import type { ArtifactHttpRequest } from "../src/httpTypes"; import { formatArtifactCheck } from "../src/diagnostics"; import { compileScriptSource } from "./compiler"; import { HOSTED_SCRIPT_GUIDE } from "./script-guide"; +import { parseProjectArchive, projectArchive } from "../src/project-archive"; export class CloudScriptService { constructor(private readonly artifacts: ArtifactService) {} @@ -51,6 +52,10 @@ export class CloudScriptService { 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_export": { + const archive = projectArchive("script", await this.readSource({ name, version_id: args.version_id as string | undefined })); + return { ...text(archive), structuredContent: archive }; + } case "script_schedule": { const target=this.artifacts.target("script",name); const backend=hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey,this.artifacts.workspace,target.name])); @@ -76,8 +81,10 @@ export class CloudScriptService { const envelope = await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).request({ ...active, request: args.request as ArtifactHttpRequest, origin: hosted.origin, trigger: "manual" }); return { ...text({ status: envelope.status, ...(link ? { url: `${hosted.publicOrigin ?? hosted.origin}/${link.slug}` } : {}) }), structuredContent: { response: envelope } }; } - case "script_remix": case "script_write": case "script_edit": case "script_restore": { - const target = this.artifacts.target("script", tool === "script_remix" ? args.new_name as string : name); + case "script_import": case "script_remix": case "script_write": case "script_edit": case "script_restore": { + const archive = tool === "script_import" ? parseProjectArchive(args.archive, "script") : undefined; + const fresh = tool === "script_remix" || tool === "script_import"; + const target = this.artifacts.target("script", fresh ? args.new_name as string : name); if (tool === "script_write" && args.expected_revision !== undefined) { const current = await scripts.readRange(input).catch(error => { if (error instanceof Error && error.message.includes("script not found")) return null; throw error; }); assertProjectRevision(args.expected_revision as string | null, current?.revision_token ?? null); @@ -85,9 +92,9 @@ export class CloudScriptService { // 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. - let generation = tool === "script_remix" ? undefined : await hosted.links.begin(target); + let generation = fresh ? undefined : await hosted.links.begin(target); const settings: LinkUpdate = await hosted.links.draft(target); - if (tool === "script_remix") { settings.slug = args.slug as string | undefined ?? target.name; settings.access = "private"; } + if (fresh) { settings.slug = args.slug as string | undefined ?? target.name; settings.access = "private"; } if (tool === "script_write") { const previous = await hosted.links.find(target); settings.slug = args.slug as string | undefined ?? settings.slug ?? previous?.slug ?? target.name; @@ -101,7 +108,8 @@ export class CloudScriptService { const destination = {...input,name:target.name}; const previous = tool === "script_write" && args.project !== undefined ? await scripts.readRange(input).catch(error => { if (error instanceof Error && error.message.includes("script not found")) return undefined; throw error; }) : undefined; const project = tool === "script_write" && args.project !== undefined ? await resolveProject(args.project, previous?.project) : undefined; - const mutation = tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string, project, expected_revision: args.expected_revision as string | null | undefined }) + const mutation = archive ? await scripts.importDraft({ workspace: this.artifacts.workspace, name: target.name, source: archive.source, project: archive.project }) + : tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string, project, expected_revision: args.expected_revision as string | null | undefined }) : tool === "script_edit" ? await scripts.editDraft({ ...input, file: args.file as string | undefined, edits: args.edits as ArtifactEdit[], expected_hash: args.expected_hash as string | undefined }) : await scripts.restore({ workspace: this.artifacts.workspace, id: args.version_id as string }); generation ??= await hosted.links.begin(target); diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index 1a1aa46..c0774a7 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -84,6 +84,17 @@ export class ScriptLibrary extends DurableObject { return {...result,remixed:true,origin:{source_name:origin.name,source_version_id:origin.id}}; }); } + importDraft(input: { workspace: string; name: string; source: string; project: ArtifactProject }) { + const { workspace, name } = key(input); + return this.ctx.storage.transactionSync(() => { + if (this.sql.exec("select name from scripts where workspace=? and name=? union all select name from script_versions where workspace=? and name=?", workspace, name, workspace, name).toArray().length) throw new Error("Destination script already exists; choose a new name"); + return { ...this.save({ ...input, workspace, name }, "import"), imported: true }; + }); + } + draftRevision(input: { workspace: string; name: string }): string { + const row = this.row(input); + return projectRevision(row.source, null, this.projectStorage.read(row.project)); + } listDrafts(input: {workspace?: string; offset?: number; limit?: number} = {}) { const {offset,limit} = page(input), workspace = input.workspace === undefined ? null : text(input.workspace,"workspace"); return this.sql.exec<{workspace: string; name: string; source_hash: string; updated_at: string; active_hash: string | null}>("select workspace,name,source_hash,updated_at,active_hash from scripts where (? is null or workspace=?) order by workspace,name limit ? offset ?",workspace,workspace,limit,offset).toArray().map(row=>({...row,id:row.name,path:`${row.workspace}/${row.name}.script.ts`,kind:"script" as const})); diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 5caa2e3..1c40b27 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -21,6 +21,7 @@ import { artifactGuideResult } from "../src/mcp/guide"; import type { ArtifactFiles } from "./files"; import { ArtifactFileError, validateFileRequest } from "./files"; import type { ArtifactFileRequest } from "../src/sdk/files"; +import { parseProjectArchive, projectArchive } from "../src/project-archive"; import { dispatchPlugin, pluginCatalog, PLUGIN_GUIDE, type PluginInvocationContext } from "./plugins"; import type { PluginRequest } from "../src/plugins/types"; @@ -78,6 +79,19 @@ export class CloudArtifactService { return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); } case "artifact_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 "artifact_export": { + const archive = projectArchive("artifact", await this.snapshot({ name: args.name as string | undefined, version_id: args.version_id as string | undefined })); + return { ...text(archive), structuredContent: archive }; + } + case "artifact_import": { + const archive = parseProjectArchive(args.archive, "artifact"); + const target = this.artifacts.target("artifact", args.new_name as string); + const slug = args.slug as string | undefined ?? target.name; + if (this.hosted) await this.hosted.links.check(target, slug); + const mutation = await this.library.importDraft({ workspace: this.workspace, name: target.name, source: archive.source, server_source: archive.server_source, project: archive.project, runtime }); + const generation = await this.hosted?.links.begin(target); + return this.mutationResult(mutation, generation, this.hosted ? { slug, access: "private" } : {}); + } case "artifact_remix": { const target = this.artifacts.target("artifact", args.new_name as string); const slug = args.slug as string | undefined ?? target.name; diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 89d4752..b180874 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -1,5 +1,6 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { MCP_TOOLS } from "../src/mcp/tools"; +import { PROJECT_ARCHIVE_SCHEMA } from "../src/project-archive-contract"; // 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)); @@ -78,6 +79,11 @@ for (const kind of ["artifact", "script"]) { } const artifactWrite = CLOUD_MCP_TOOLS.find(tool => tool.name === "artifact_write")!; Object.assign(artifactWrite.inputSchema.properties!, slugProperties); +Object.assign(CLOUD_MCP_TOOLS.find(tool => tool.name === "artifact_import")!.inputSchema.properties!, { slug: slugProperties.slug }); +CLOUD_MCP_TOOLS.push( + { name: "script_export", description: "Export script source, helpers and exact dependency snapshot as a complete versioned project archive. Includes no secrets, database or schedules.", annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { name: { type: "string" }, version_id: { type: "string" } }, oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false } }, + { name: "script_import", description: "Import a complete script project archive under a fresh new_name and private URL. Preserves dependency bytes without network resolution. Copies no secrets, storage or schedules; invalid code stays as a draft with diagnostics.", inputSchema: { type: "object", properties: { new_name: { type: "string" }, slug: slugProperties.slug, archive: PROJECT_ARCHIVE_SCHEMA }, required: ["new_name", "archive"], additionalProperties: false } }, +); function addScriptTool(name: string, description: string, properties: Record, required: string[] = []) { CLOUD_MCP_TOOLS.push({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } }); } diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 31f29ab..3340447 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -772,3 +772,71 @@ test("configured public origin changes generated links without changing local au expect(denied.status).toBe(403); } finally { await proxied.dispose(); } }, 60000); + +test("complete project archives round trip into fresh private scripts and artifacts with no live data", async () => { + const project = { files: { "lib/value.ts": 'export {value} from "archive-value";' }, dependencies: { "archive-value": "1.0.0" }, lock: { + "node_modules/archive-value/package.json": '{"name":"archive-value","version":"1.0.0","main":"index.js","types":"index.d.ts"}', + "node_modules/archive-value/index.js": "export const value = 7;", + "node_modules/archive-value/index.d.ts": "export const value: number;", + "node_modules/archive-value/padding.json": JSON.stringify({ padding: "x".repeat(1100000) }), + } }; + const archive = (kind: "script" | "artifact", source: string, server_source: string | null = null) => ({ format: "sidequery-artifacts-project", version: 1, kind, name: "exported-project", source, server_source, project }); + const call = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }); + if (result.isError) throw new Error(JSON.stringify(result)); + return (result.structuredContent ?? JSON.parse((result.content as {text: string}[])[0]!.text)) as any; + }; + const scriptSource = `import {value} from "./lib/value"; +export default { fetch(request, env) { + env.sql.exec("create table if not exists app_rows(value integer)"); + if (new URL(request.url).pathname === "/write") env.sql.exec("insert into app_rows values(1)"); + return Response.json({value, count: env.sql.exec("select count(*) as n from app_rows").toArray()[0].n, secret: env.secrets.TOKEN ?? null}); +}};`; + const scriptArchive = archive("script", scriptSource); + const originScript = await call("script_import", { new_name: "archive-origin", archive: scriptArchive }); + expect(originScript).toMatchObject({ imported: true, ok: true, access: "private" }); + await call("script_secrets", { name: "archive-origin", secrets: { TOKEN: "original-only" } }); + await call("script_run", { name: "archive-origin", request: { path: "/write" } }); + await call("script_schedule", { name: "archive-origin", action: "set", interval_seconds: 3600 }); + await call("artifact_link", { kind: "script", name: "archive-origin", slug: "archive-origin", access: "public" }); + const exportedScript = await call("script_export", { name: "archive-origin" }); + expect(exportedScript).toEqual({ ...scriptArchive, name: "archive-origin" }); + expect(Object.keys(exportedScript).sort()).toEqual(["format", "kind", "name", "project", "server_source", "source", "version"]); + const copiedScript = await call("script_import", { new_name: "archive-copy", archive: exportedScript }); + expect(copiedScript.access).toBe("private"); + const run = await call("script_run", { name: "archive-copy", request: { path: "/" } }); + expect(JSON.parse(atob(run.response.body))).toEqual({ value: 7, count: 0, secret: null }); + expect((await call("script_schedule", { name: "archive-copy" })).schedule).toBeNull(); + expect((await client.callTool({ name: "script_import", arguments: { new_name: "archive-copy", archive: exportedScript } })).isError).toBe(true); + const apiScript = await fetch(`${origin}/api/tools?workspace=test`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "script_import", arguments: { new_name: "archive-api-copy", archive: exportedScript } }) }); + expect(apiScript.status).toBe(200); + expect((await apiScript.json() as any).structuredContent).toMatchObject({ imported: true, ok: true, access: "private" }); + const downloadedScript = await (await fetch(`${origin}/api/source?workspace=test&kind=script&name=archive-origin&format=project`)).json(); + expect(downloadedScript).toEqual(exportedScript); + + const clientSource = 'import {value} from "./lib/value"; export default function App() { return
{value}
; }'; + const serverSource = `import {DurableObject} from "cloudflare:workers"; +export class ArtifactServer extends DurableObject { fetch(request: Request) { + this.ctx.storage.sql.exec("create table if not exists app_rows(value integer)"); + if (new URL(request.url).pathname === "/write") this.ctx.storage.sql.exec("insert into app_rows values(1)"); + return Response.json({count: this.ctx.storage.sql.exec("select count(*) as n from app_rows").toArray()[0].n}); +}}`; + const artifactArchive = archive("artifact", clientSource, serverSource); + expect(await call("artifact_import", { new_name: "archive-app", archive: artifactArchive })).toMatchObject({ imported: true, ok: true, access: "private" }); + await call("artifact_request", { name: "archive-app", request: { path: "/write" } }); + const upload = (await call("artifact_files", { name: "archive-app", request: { operation: "upload", name: "original.txt", size: 3, type: "text/plain" } })).result; + expect((await fetch(upload.url, { method: "PUT", body: "one" })).status).toBe(201); + const exportedArtifact = await call("artifact_export", { name: "archive-app" }); + expect(exportedArtifact).toEqual({ ...artifactArchive, name: "archive-app" }); + expect(await call("artifact_import", { new_name: "archive-app-copy", archive: exportedArtifact })).toMatchObject({ imported: true, ok: true, access: "private" }); + const response = await call("artifact_request", { name: "archive-app-copy", request: { path: "/" } }); + expect(JSON.parse(atob(response.response.body))).toEqual({ count: 0 }); + expect((await call("artifact_files", { name: "archive-app", request: { operation: "list" } })).result.files).toHaveLength(1); + expect((await call("artifact_files", { name: "archive-app-copy", request: { operation: "list" } })).result.files).toEqual([]); + const downloadedArtifact = await (await fetch(`${origin}/api/source?workspace=test&name=archive-app&format=project`)).json(); + expect(downloadedArtifact).toEqual(exportedArtifact); + const versions = await call("artifact_history", { name: "archive-app" }); + expect(await call("artifact_export", { version_id: versions.versions[0].version_id })).toEqual(exportedArtifact); + expect((await client.callTool({ name: "artifact_import", arguments: { new_name: "archive-app-copy", archive: exportedArtifact } })).isError).toBe(true); + await expect(client.callTool({ name: "artifact_import", arguments: { new_name: "archive-reject", archive: { ...exportedArtifact, secrets: {} } } })).rejects.toThrow("Invalid tool arguments"); +}, 60000); diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 7f8e038..2a14516 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -8,7 +8,8 @@ import { ManagedArtifactService } from "./managed-service"; // the MCP SDK constructs its top-level schemas (Zod 4.5.4). import { authenticateBetterAuth, getArtifactAuth, ArtifactAuthConfigurationError, type BetterAuthEnvironment, type ArtifactUser } from "./better-auth"; import { handleCloudMcp } from "./mcp"; -import { readRequestText } from "./http"; +import { readRequestText, readToolBody } from "./http"; +import { projectArchive } from "../src/project-archive"; import { ArtifactBackend } from "./backend"; import { ArtifactFiles, ArtifactFileError, TRANSFER_PATH } from "./files"; import type { ArtifactFileRequest } from "../src/sdk/files"; @@ -114,8 +115,8 @@ app.get("/api/session", c => { app.all("/mcp", async c => { let body: unknown; if (c.req.method === "POST") { - try { body = JSON.parse(await readRequestText(c.req.raw, 1024 * 1024)); } - catch (error) { return c.json({ error: error instanceof RangeError ? "Request exceeds 1 MiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } + try { body = await readToolBody(c.req.raw, true); } + catch (error) { return c.json({ error: error instanceof RangeError ? error.message : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } } return handleCloudMcp(c.req.raw, c.get("service"), body); }); @@ -159,8 +160,8 @@ app.post("/api/artifact/files", async c => { }); app.post("/api/tools", async c => { let input: { name?: string; arguments?: Record }; - try { input = JSON.parse(await readRequestText(c.req.raw, 1024 * 1024)); } - catch (error) { return c.json({ error: error instanceof RangeError ? "Request exceeds 1 MiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } + try { input = await readToolBody(c.req.raw) as typeof input; } + catch (error) { return c.json({ error: error instanceof RangeError ? error.message : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } if (!input || typeof input.name !== "string" || !Object.hasOwn(toolValidators, input.name)) return c.json({ error: "Unknown tool" }, 400); const validate = toolValidators[input.name as keyof typeof toolValidators]; if (!validate(input.arguments ?? {})) return c.json({ error: "Invalid tool arguments" }, 400); @@ -169,12 +170,20 @@ app.post("/api/tools", async c => { app.get("/api/source", async c => { if (c.req.query("kind") === "script") { const source = await c.get("service").scriptReadSource({ name: c.req.query("name"), version_id: c.req.query("version") }); + if (c.req.query("format") === "project") { + c.header("Content-Disposition", `attachment; filename="project.artifact-project.json"; filename*=UTF-8''${encodeURIComponent(source.name + ".artifact-project.json")}`); + return c.json(projectArchive("script", source)); + } if (c.req.query("format") === "json") return c.json(source); c.header("Content-Type", "text/plain; charset=utf-8"); if (c.req.query("download") === "1") c.header("Content-Disposition", 'attachment; filename="script.ts"'); return c.body(source.source); } const snapshot = await c.get("service").snapshot({ name: c.req.query("name"), version_id: c.req.query("version") }); + if (c.req.query("format") === "project") { + c.header("Content-Disposition", `attachment; filename="project.artifact-project.json"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".artifact-project.json")}`); + return c.json(projectArchive("artifact", snapshot)); + } if (c.req.query("format") === "json") return c.json(snapshot); c.header("Content-Type", "text/plain; charset=utf-8"); if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="artifact.artifact.tsx"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".artifact.tsx")}`); diff --git a/docs/project-portability.md b/docs/project-portability.md new file mode 100644 index 0000000..b0efbe4 --- /dev/null +++ b/docs/project-portability.md @@ -0,0 +1,23 @@ +# Project archives + +Export project downloads the selected saved working copy or historical revision as a version 1 `.artifact-project.json` archive. Unsaved browser changes are excluded. Download entrypoint remains available for a single source file. + +An archive includes the artifact client and optional native server, or a script entrypoint, relative helper modules, exact dependency declarations and the complete archived package source/type snapshot. Import preserves these bytes without fetching packages or running installation scripts. Deployment-provided SDK and plugin packages still come from the destination runtime; source must validate there before it can deploy. + +Import project requires a fresh name and optionally a fresh URL slug. Hosted imports start private with fresh databases, files, UI state, secrets and schedules. Existing working and historical identities cannot be overwritten. Invalid imports remain saved drafts with diagnostics and have no live URL until corrected. An archive copies no runtime data or access settings. Source code may contain embedded application data, so review it before sharing. + +The archive format is `sidequery-artifacts-project`, version `1`, with `kind`, `name`, `source`, `server_source` and `project` (`files`, `dependencies`, `lock`). Unknown fields and versions, unsafe paths, incomplete snapshots and oversized projects are rejected. The compact archive is limited to 10 MiB; entrypoints retain the 256 KiB limit, helpers the 64-file/1 MiB limit, and package snapshots the 4096-file/8 MiB limit. + +MCP exposes `artifact_export` and `script_export` with exactly one of `name` or `version_id`. `artifact_import` and `script_import` accept `new_name`, `archive` and an optional hosted `slug`. Ordinary writes still treat `project.lock` as read-only; importing a complete archive is the explicit way to supply a saved dependency snapshot. Only import requests may exceed the ordinary 1 MiB management body limit. + +Filesystem workflows: + +```sh +artifacts export dashboard --output dashboard.artifact-project.json +artifacts export --version VERSION_ID --output archived.artifact-project.json +artifacts import dashboard-copy --file dashboard.artifact-project.json +artifacts export handler --kind script --output handler.artifact-project.json +artifacts import handler-copy --file handler.artifact-project.json +``` + +Export refuses to replace an existing output file. Import also accepts `--stdin`. Local MCP and the filesystem gallery support interactive artifact imports/exports. The CLI additionally stores and exports script archives. Filesystem previews do not execute scripts or native servers; their source is preserved for later hosted deployment. Artifact history, remix and restore preserve backend source alongside the client and dependencies. diff --git a/e2e/gallery-move.test.ts b/e2e/gallery-move.test.ts index 50fee50..3c776a1 100644 --- a/e2e/gallery-move.test.ts +++ b/e2e/gallery-move.test.ts @@ -72,6 +72,17 @@ test("gallery moves artifacts and scripts between libraries, preserves selection await panel.getByRole("button", { name: "Cancel", exact: true }).click(); expect(await panel.count()).toBe(0); expect(moves).toHaveLength(beforeCancel); + if (kind === "artifact") await page.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + const editor = page.getByRole("textbox", { name: kind === "script" ? "script.ts" : `${name}.artifact.tsx`, exact: true }); + const savedSource = await editor.innerText(); + await editor.fill("unsaved project source"); + await page.getByRole("button", { name: "Move", exact: true }).click(); + page.once("dialog", dialog => void dialog.dismiss()); + await panel.getByRole("button", { name: "Move to team library", exact: true }).click(); + expect(moves).toHaveLength(beforeCancel); + expect(await editor.innerText()).toBe("unsaved project source"); + await editor.fill(savedSource); + await panel.getByRole("button", { name: "Cancel", exact: true }).click(); await page.getByRole("button", { name: "Move", exact: true }).click(); collision = true; await panel.getByRole("button", { name: "Move to team library", exact: true }).click(); diff --git a/e2e/gallery-navigation.test.ts b/e2e/gallery-navigation.test.ts index e7d4c4c..7b57bd6 100644 --- a/e2e/gallery-navigation.test.ts +++ b/e2e/gallery-navigation.test.ts @@ -157,7 +157,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati await mobile.frameLocator('iframe[title="Preview of Campaign overview"]').getByRole("heading", { name: "Campaign performance" }).waitFor(); await noHorizontalOverflow(mobile); await mobile.getByLabel("More actions", { exact: true }).click(); - expect(await mobile.getByRole("link", { name: "Download source", exact: true }).isVisible()).toBe(true); + expect(await mobile.getByRole("link", { name: "Download entrypoint", exact: true }).isVisible()).toBe(true); await mobile.getByLabel("More actions", { exact: true }).click(); await screenshot(mobile, "gallery-mobile-preview"); await mobile.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); diff --git a/e2e/gallery-transfer.test.ts b/e2e/gallery-transfer.test.ts new file mode 100644 index 0000000..7f33c26 --- /dev/null +++ b/e2e/gallery-transfer.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { chromium } from "playwright"; +import { createArtifactServer } from "../src/serve"; +import { projectArchive } from "../src/project-archive"; +import { VALID_ARTIFACT } from "../src/test/fixtures"; + +test("gallery imports a fresh complete project and exports its saved snapshot through authenticated downloads", async () => { + const directory = await mkdtemp(join(tmpdir(), "gallery-transfer-")); + const archive = projectArchive("artifact", { name: "portable", source: VALID_ARTIFACT, server_source: "preserved server source", project: { files: { "lib/value.ts": "export const value = 7;" }, dependencies: {}, lock: { "node_modules/archived/index.js": "export const value = 7;" } } }); + const server = await createArtifactServer({ artifactsDir: directory, historyPath: join(directory, "history.sqlite"), gallery: true }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ acceptDownloads: true, viewport: { width: 1440, height: 960 } }); + await page.route("**/api/session", route => route.fulfill({ json: { authMode: "better-auth", user: { id: "test", name: "Test owner", email: "test@example.test" } } })); + await page.goto(server.url); + await page.getByRole("button", { name: "Import project", exact: true }).click(); + await page.getByLabel("Project archive", { exact: true }).setInputFiles({ name: "portable.artifact-project.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(archive)) }); + await page.waitForFunction(() => (document.querySelector('[aria-label="Imported project name"]') as HTMLInputElement)?.value === "portable-import"); + expect(await page.getByText("Includes backend source", { exact: false }).innerText()).toContain("1 helper files"); + await page.getByRole("button", { name: "Import as new project", exact: true }).click(); + await page.getByRole("status").filter({ hasText: "Project imported." }).waitFor(); + await page.getByRole("button", { name: "Import as new project", exact: true }).hover(); + expect(await page.getByRole("button", { name: "Import as new project", exact: true }).evaluate(element => getComputedStyle(element).backgroundColor)).toBe("rgb(237, 243, 248)"); + expect(await page.getByRole("heading", { level: 1 }).innerText()).toBe("portable-import"); + if (process.env.GALLERY_SCREENSHOT_DIR) { await mkdir(process.env.GALLERY_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: join(process.env.GALLERY_SCREENSHOT_DIR, "gallery-project-import.png"), fullPage: true }); } + await page.getByRole("button", { name: "Close import", exact: true }).click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.getByRole("link", { name: "Export project", exact: true }).click()]); + expect(download.suggestedFilename()).toBe("portable-import.artifact-project.json"); + const path = join(directory, "export.json"); await download.saveAs(path); + expect(await Bun.file(path).json()).toEqual({ ...archive, name: "portable-import" }); + expect(await page.getByRole("link", { name: "Download entrypoint", exact: true }).getAttribute("href")).not.toContain("format=project"); + const snapshot = await fetch(`${server.url}/api/source?name=portable-import&format=project`); + expect(await snapshot.json()).toEqual({ ...archive, name: "portable-import" }); + if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: join(process.env.GALLERY_SCREENSHOT_DIR, "gallery-project-source.png"), fullPage: true }); + } finally { await browser.close(); server.stop(); await rm(directory, { recursive: true, force: true }); } +}, 30000); diff --git a/package.json b/package.json index e67ac63..9669fb4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "docs/celld-deployment.md", "docs/releasing.md", "docs/scripts.md", + "docs/project-portability.md", "docs/runtime-plugins.md", "docs/routing.md", "docs/files.md", @@ -92,7 +93,11 @@ "src/typecheck.ts", "src/viewer.ts", "src/localProject.ts", + "src/project-archive.ts", + "src/project-archive-contract.ts", + "src/gallery/project-transfer.tsx", "cloudflare/project.ts", + "cloudflare/http.ts", "cloudflare/schedule.ts", "src/gallery/execution-controls.tsx", "src/gallery/project-editor.tsx", @@ -107,7 +112,7 @@ "mcp": "bun run src/cli.ts mcp", "test": "bun test src scripts/prepare-plugins.test.ts examples/runner-status --timeout 30000", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", "typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", diff --git a/scripts/package.integration.test.ts b/scripts/package.integration.test.ts index e7f1a1d..f55e7f9 100644 --- a/scripts/package.integration.test.ts +++ b/scripts/package.integration.test.ts @@ -177,6 +177,10 @@ test("published tarball runs the CLI, gallery, and stdio MCP outside a checkout" const compiled = JSON.parse((await run([artifact, "compile", "package-smoke", ...args], consumer, isolatedEnv)).stdout); expect(compiled.ok).toBe(true); expect(compiled.bytes).toBeGreaterThan(1_000); + const projectArchive = join(consumer, "package-smoke.artifact-project.json"); + await run([artifact, "export", "package-smoke", "--output", projectArchive, ...args], consumer, isolatedEnv); + const imported = JSON.parse((await run([artifact, "import", "package-copy", "--file", projectArchive, ...args], consumer, isolatedEnv)).stdout); + expect(imported).toMatchObject({ ok: true, imported: true, name: "package-copy" }); gallery = Bun.spawn([artifact, "web", "--port", "0", ...args], { cwd: consumer, diff --git a/src/cli.test.ts b/src/cli.test.ts index 48f0ec4..36336f4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -203,3 +203,19 @@ test("host install opts into login startup without network-provider configuratio expect(installed).toBe(true); await expect(runServerCommand(parseArgs(["host", "install", "--tailscale-login", "owner"]), { manager })).rejects.toThrow("Unknown host option"); }); + +test("CLI imports and exports complete versioned projects and rejects overwriting exports", async () => { + const directory = tempDir(); + const common = ["--dir", directory, "--history-db", join(directory, "history.sqlite")]; + const archive = { format: "sidequery-artifacts-project", version: 1, kind: "artifact", name: "portable", source: VALID_ARTIFACT, server_source: "preserved backend", project: { files: {}, dependencies: {}, lock: {} } }; + const imported = await runCli(["import", "fresh", "--stdin", ...common], {}, JSON.stringify(archive)); + expect(imported.exitCode).toBe(0); + const exported = await runCli(["export", "fresh", ...common]); + expect(exported.exitCode).toBe(0); + expect(JSON.parse(exported.stdout)).toEqual({ ...archive, name: "fresh" }); + const output = join(directory, "export.json"); + expect((await runCli(["export", "fresh", "--output", output, ...common])).exitCode).toBe(0); + expect((await runCli(["export", "fresh", "--output", output, ...common])).exitCode).toBe(1); + expect(JSON.parse(readFileSync(output, "utf8"))).toEqual({ ...archive, name: "fresh" }); + expect((await runCli(["import", "fresh", "--file", output, ...common])).exitCode).toBe(1); +}); diff --git a/src/cli.ts b/src/cli.ts index 02e93cf..dc078de 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { flagBoolean, flagString, parseArgs, type ParsedArgs } from "./args"; @@ -14,6 +14,7 @@ import { createArtifactServer } from "./serve"; import { ArtifactService } from "./service"; import { historyPath } from "./history"; import { PLUGIN_ROOT } from "./paths"; +import type { ProjectArchive } from "./project-archive-contract"; type ServerManager = Pick; @@ -140,6 +141,27 @@ async function main(): Promise { return; } + if (args.command === "export") { + const versionId = flagString(args.flags, "version"); + const name = args.positionals[0]; + if (Boolean(name) === Boolean(versionId)) throw new Error("use export NAME or export --version ID"); + const kind = (flagString(args.flags, "kind") ?? (name?.endsWith(".script.ts") ? "script" : "artifact")) as ProjectArchive["kind"]; + const archive = service.exportProject({ name, version_id: versionId, kind }); + const output = flagString(args.flags, "output"); + if (output) { writeFileSync(resolve(output), JSON.stringify(archive, null, 2) + "\n", { flag: "wx" }); printJson({ ok: true, path: resolve(output) }); } + else printJson(archive); + return; + } + + if (args.command === "import") { + const name = args.positionals[0]; requireArg(name, "missing new project name"); + if (args.positionals.length !== 1 || !flagString(args.flags, "file") && !flagBoolean(args.flags, "stdin")) throw new Error("use import NEW_NAME --file ARCHIVE or --stdin"); + const result = service.importProject(name, JSON.parse(await readContents(args))); + printJson(result); + if (!result.ok) process.exitCode = 1; + return; + } + if (args.command === "history") { printJson({ ok: true, versions: service.history(args.positionals[0]) }); return; @@ -304,6 +326,10 @@ Usage: artifacts restore VERSION_ID artifacts remix SOURCE NEW_NAME artifacts remix NEW_NAME --version VERSION_ID + artifacts export NAME [--kind artifact|script] [--output ARCHIVE_JSON] + artifacts export --version VERSION_ID [--output ARCHIVE_JSON] + artifacts import NEW_NAME --file ARCHIVE_JSON + artifacts import NEW_NAME --stdin artifacts host [--port 4786] [--state-dir PATH] artifacts host install [--port 4786] artifacts host start [--port 4786] [--at-login] diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 19645ab..8c2db43 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -10,6 +10,7 @@ import { Select } from "./select"; import { MovePanel } from "./move"; import { SourceEditor } from "./source-editor"; import { DraftProvider, confirmLeavingDrafts, useDrafts } from "./drafts"; +import { ProjectImportPanel } from "./project-transfer"; type Scope = "current" | "all"; type DetailTab = "preview" | "source" | "activity" | "requests" | "secrets"; @@ -123,7 +124,7 @@ const styles = ` .source-form > .script-fields { padding: 12px 16px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } .source-actions p { margin: 0; color: var(--muted); } - button.primary-action { background: #d8e3ec; color: #17202a; border-color: #d8e3ec; font-weight: 600; } + .gallery-app button.primary-action { background: #d8e3ec; color: #17202a; border-color: #d8e3ec; font-weight: 600; } button.primary-action:disabled { opacity: .45; } .deployment-status { margin: 6px 0 0; color: var(--muted); } .save-feedback { padding: 8px 16px; flex-shrink: 0; max-height: 30vh; overflow: auto; } @@ -136,6 +137,12 @@ const styles = ` .agent-onboarding textarea { min-height: 100px; } .live-data-note { padding: 8px 16px; margin: 0; border-bottom: 1px solid var(--line); color: var(--muted); } .artifact-execution fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } + .project-import { padding: 16px 20px; max-height: 50vh; overflow: auto; } + .project-import label { display: block; margin: 8px 0; } + .project-import h2 { margin: 0 0 8px; font-size: 16px; } + .project-import input { background: var(--raised); } + .project-import input[type=file] { height: auto; } + .project-import pre { max-height: 160px; overflow: auto; white-space: pre-wrap; } .source-actions .source-readonly { margin-right: auto; } .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } .save-conflict button { border-color: var(--line); margin-right: 8px; } @@ -183,6 +190,7 @@ const styles = ` .refresh-error { padding: 10px 16px; margin: 0; border-bottom: 1px solid var(--line); background: var(--panel); } @media (hover: hover) and (pointer: fine) { button:hover:not(:disabled), .download-link:hover { background: var(--raised); } + .gallery-app button.primary-action:hover:not(:disabled) { background: #edf3f8; } .artifact-row[aria-current="true"]:hover { background: var(--selected); } .detail-actions .open-link:hover { background: #fff; } .view-control button:hover:not([aria-pressed="true"]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } @@ -304,6 +312,7 @@ function App() { const [kindFilter, setKindFilter] = useState("all"); const [showLinks, setShowLinks] = useState(false); const [showMove, setShowMove] = useState(false); + const [showImport, setShowImport] = useState(false); const [sourceVisited, setSourceVisited] = useState(null); const [mobileDetail, setMobileDetail] = useState(false); const [narrowLayout, setNarrowLayout] = useState(() => window.matchMedia("(max-width: 760px)").matches); @@ -461,6 +470,7 @@ function App() { const downloadUrl = selectedArtifact && resolvedVersion ? artifactUrl("/api/source", selectedArtifact, resolvedVersion, true) : ""; + const exportUrl = downloadUrl ? `${downloadUrl}&format=project` : ""; useEffect(() => { const pending = new Set(); @@ -578,14 +588,15 @@ function App() { const downloadSource = async (event: React.MouseEvent) => { if (!user) return; event.preventDefault(); + const downloadHref = event.currentTarget.href; try { - const response = await fetch(downloadUrl, { headers: { Accept: "text/plain" } }); + const response = await fetch(downloadHref); if (redirectExpiredSession(response)) return; if (!response.ok) throw new Error(`Source download failed (${response.status})`); const url = URL.createObjectURL(await response.blob()); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${selectedArtifact?.name ?? "artifact"}${selectedArtifact?.kind === "script" ? ".ts" : ".artifact.tsx"}`; + anchor.download = new URL(downloadHref).searchParams.get("format") === "project" ? `${selectedArtifact?.name ?? "artifact"}.artifact-project.json` : `${selectedArtifact?.name ?? "artifact"}${selectedArtifact?.kind === "script" ? ".ts" : ".artifact.tsx"}`; anchor.click(); URL.revokeObjectURL(url); } catch (error) { @@ -601,6 +612,7 @@ function App() { Artifacts {gallery?.workspace ? {gallery.workspace} : null}
+ {gallery?.libraryScope ? { + const file = event.target.files?.[0]; setArchive(null); setError(""); setStatus(""); setDiagnostics(null); + if (!file) return; + if (file.size > MAX_PROJECT_ARCHIVE_BYTES) { setError("Project archive exceeds 10 MiB."); return; } + setBusy(true); + void file.text().then(text => { + const value = JSON.parse(text) as ProjectArchive; + if (!value || value.format !== PROJECT_ARCHIVE_FORMAT || value.version !== 1 || !["artifact", "script"].includes(value.kind) || typeof value.name !== "string" || typeof value.source !== "string" || !value.project || !value.project.files || !value.project.dependencies || !value.project.lock) throw new Error("Choose a complete version 1 project archive exported by Artifacts."); + if (!hosted && value.kind === "script") throw new Error("Import script archives with artifacts import NEW_NAME --file ARCHIVE_JSON."); + setArchive(value); setName(`${value.name}-import`); setSlug(""); + }).catch(error => setError(error instanceof Error ? error.message : String(error))).finally(() => setBusy(false)); + }} /> + {archive ?

{archive.kind === "script" ? "Script" : "Interactive artifact"} · {Object.keys(archive.project.files).length} helper files · {Object.keys(archive.project.dependencies).length} dependencies{archive.server_source ? " · Includes backend source" : ""}

: null} + + {hosted ? : null} + + + {error ?

{error}

: null} + {diagnostics ?
Import diagnostics
{JSON.stringify(diagnostics, null, 2)}
: null} + {status ?

{status}

: null} + ; +} diff --git a/src/history.ts b/src/history.ts index 40e1035..c01268a 100644 --- a/src/history.ts +++ b/src/history.ts @@ -1,4 +1,4 @@ -import { readLocalProject } from "./localProject"; +import { readLocalProject, readLocalServer } from "./localProject"; import { normalizeProject, projectSourceHash, type ArtifactProject } from "../cloudflare/project"; import { Database } from "bun:sqlite"; import { createHash, randomUUID } from "node:crypto"; @@ -88,22 +88,24 @@ export class ArtifactHistory { `); const columns = this.db.query("pragma table_info(versions)").all() as { name: string }[]; if (!columns.some(column => column.name === "project_json")) this.db.exec("alter table versions add column project_json text"); + if (!columns.some(column => column.name === "server_source")) this.db.exec("alter table versions add column server_source text"); }).immediate(); } close() { this.db.close(); } - capture(input: { workspace: string; name: string; sourcePath: string; source: string; project?: ArtifactProject; runtime: string; reason?: string; restoredFrom?: string; force?: boolean }): Version { + capture(input: { workspace: string; name: string; sourcePath: string; source: string; server_source?: string | null; project?: ArtifactProject; runtime: string; reason?: string; restoredFrom?: string; force?: boolean }): Version { return this.db.transaction(() => { const workspace = resolve(input.workspace); this.db.query("insert or ignore into artifacts values (?, ?, ?, ?, ?)").run(randomUUID(), workspace, input.name, resolve(input.sourcePath), new Date().toISOString()); const artifact = this.db.query("select id from artifacts where workspace = ? and name = ?").get(workspace, input.name) as { id: string }; const project = input.project ?? readLocalProject(input.sourcePath); - const sourceHash = projectSourceHash(input.source, project); + const server = input.server_source === undefined ? readLocalServer(input.sourcePath) : input.server_source; + const sourceHash = server === null ? projectSourceHash(input.source, project) : hash(JSON.stringify([input.source, server, project])); const latest = this.db.query("select id, revision, source_hash from versions where artifact_id = ? order by revision desc limit 1").get(artifact.id) as Version | null; if (!input.force && latest && latest.source_hash === sourceHash) return this.version(latest.id)!; const id = randomUUID(); - this.db.query("insert into versions (id, artifact_id, revision, source, source_hash, runtime, created_at, reason, restored_from, project_json) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, artifact.id, (latest?.revision ?? 0) + 1, input.source, sourceHash, input.runtime, new Date().toISOString(), input.reason ?? "served", input.restoredFrom ?? null, JSON.stringify(project)); + this.db.query("insert into versions (id, artifact_id, revision, source, source_hash, runtime, created_at, reason, restored_from, project_json, server_source) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, artifact.id, (latest?.revision ?? 0) + 1, input.source, sourceHash, input.runtime, new Date().toISOString(), input.reason ?? "served", input.restoredFrom ?? null, JSON.stringify(project), server); return this.version(id)!; }).immediate(); } diff --git a/src/localProject.ts b/src/localProject.ts index 94596e2..aa49007 100644 --- a/src/localProject.ts +++ b/src/localProject.ts @@ -7,6 +7,23 @@ import { sandboxToDiagnostics } from "./diagnostics"; import { assertRegularArtifact, replaceArtifactSource } from "./artifactFile"; export const localProjectPath = (path: string) => `${path}.project.json`; +export const localServerPath = (path: string) => path.replace(/\.tsx$/, ".server.ts"); +export function readLocalServer(path: string): string | null { + const file = localServerPath(path); + if (!existsSync(file)) return null; + assertRegularArtifact(file); + return readFileSync(file, "utf8"); +} +export function writeLocalServer(path: string, source: string | null, exclusive = false): void { + const file = localServerPath(path); + if (source === null) { + if (existsSync(file)) { assertRegularArtifact(file); rmSync(file); } + return; + } + if (exclusive) { writeFileSync(file, source, { flag: "wx" }); return; } + if (existsSync(file)) assertRegularArtifact(file); + replaceArtifactSource(file, source, existsSync(file) ? readFileSync(file, "utf8") : undefined, "server source update"); +} export function readLocalProject(path: string): ArtifactProject { const file = localProjectPath(path); if (!existsSync(file)) return emptyProject(); diff --git a/src/mcp/guide.ts b/src/mcp/guide.ts index b1ec99a..a707e0a 100644 --- a/src/mcp/guide.ts +++ b/src/mcp/guide.ts @@ -81,7 +81,7 @@ Files use the same library + workspace + artifact identity as the database and s For browser TSX, embed data in the source or use artifactFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to native server source, which imports DurableObject from "cloudflare:workers". -artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. +artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. artifact_export returns a complete version 1 project archive including helpers and exact package source/type snapshots, with no runtime data or secrets. artifact_import accepts that archive and a fresh new_name; hosted imports start private with new storage. Filesystem imports preserve backend source for deployment but do not execute it. `; } diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index 713b724..b870c59 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -8,6 +8,7 @@ const PROTOCOL_VERSION = "2025-06-18"; export { MCP_TOOLS } from "./tools"; import { MCP_TOOLS } from "./tools"; +import { parseProjectArchive } from "../project-archive"; export async function handleMcpRequest( request: JsonRpcRequest, @@ -55,6 +56,12 @@ async function callTool( args: Record, ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean; structuredContent?: Record; _meta?: Record }> { if (name === "artifact_guide") return artifactGuideResult(); + if (name === "artifact_export") return text(JSON.stringify(service.exportProject({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }))); + if (name === "artifact_import") { + const archive = parseProjectArchive(args.archive, "artifact"); + const result = service.importProject(args.new_name as string, archive); + return withPreview(service, result, { name: result.name }); + } if (name === "artifact_read" || name === "artifact_edit") { if (typeof args.name !== "string") throw new Error("artifact name must be a string"); if (name === "artifact_read") { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 420011e..5127340 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,6 +1,19 @@ import { ARTIFACTS_APP_META } from "./app-contract"; +import { PROJECT_ARCHIVE_SCHEMA } from "../project-archive-contract"; export const MCP_TOOLS = [ + { + name: "artifact_export", + description: "Export a complete versioned project archive: client, server, helper modules and exact dependency snapshot. Select name or version_id. Never includes UI state, database/files, secrets or URL policy.", + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + inputSchema: { type: "object", properties: { name: { type: "string" }, version_id: { type: "string" } }, oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, + }, + { + name: "artifact_import", + _meta: ARTIFACTS_APP_META, + description: "Import a complete project archive under a fresh new_name. Never overwrites source or historical identities, copies no live data, and creates a private URL in hosted runtimes. Preserves archived dependency bytes without fetching or installing packages. Invalid source remains an imported draft with diagnostics. Filesystem runtimes preserve server code for later deployment but do not execute it.", + inputSchema: { type: "object", properties: { new_name: { type: "string" }, archive: PROJECT_ARCHIVE_SCHEMA }, required: ["new_name", "archive"], additionalProperties: false }, + }, { name: "artifact_guide", description: "Read the Sidequery Artifacts SDK contract: exports, host APIs, component conventions, restrictions, and validation behavior. Call before creating an artifact if you have not read it in this conversation.", diff --git a/src/project-archive-contract.ts b/src/project-archive-contract.ts new file mode 100644 index 0000000..fa31252 --- /dev/null +++ b/src/project-archive-contract.ts @@ -0,0 +1,25 @@ +import type { ArtifactProject } from "../cloudflare/project"; + +export const PROJECT_ARCHIVE_FORMAT = "sidequery-artifacts-project"; +export const MAX_PROJECT_ARCHIVE_BYTES = 10 * 1024 * 1024; +export const PROJECT_IMPORT_REQUEST_BYTES = MAX_PROJECT_ARCHIVE_BYTES + 8192; +export type ProjectArchive = { + format: typeof PROJECT_ARCHIVE_FORMAT; + version: 1; + kind: "artifact" | "script"; + name: string; + source: string; + server_source: string | null; + project: ArtifactProject; +}; +export const PROJECT_ARCHIVE_SCHEMA = { + type: "object", properties: { + format: { const: PROJECT_ARCHIVE_FORMAT }, version: { const: 1 }, kind: { enum: ["artifact", "script"] }, + name: { type: "string", minLength: 1, maxLength: 255 }, source: { type: "string", maxLength: 262144 }, server_source: { type: ["string", "null"], maxLength: 262144 }, + project: { type: "object", properties: { + files: { type: "object", maxProperties: 64, additionalProperties: { type: "string" } }, + dependencies: { type: "object", maxProperties: 32, additionalProperties: { type: "string" } }, + lock: { type: "object", maxProperties: 4096, additionalProperties: { type: "string" } }, + }, required: ["files", "dependencies", "lock"], additionalProperties: false }, + }, required: ["format", "version", "kind", "name", "source", "server_source", "project"], additionalProperties: false, +}; diff --git a/src/project-archive.test.ts b/src/project-archive.test.ts new file mode 100644 index 0000000..b86023d --- /dev/null +++ b/src/project-archive.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { existsSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { ArtifactService } from "./service"; +import { parseProjectArchive, projectArchive } from "./project-archive"; +import { localProjectPath, localServerPath } from "./localProject"; +import { VALID_ARTIFACT, tempDir } from "./test/fixtures"; + +const serverSource = 'import { DurableObject } from "cloudflare:workers"; export class ArtifactServer extends DurableObject { fetch() { return Response.json({ok:true}); } }'; +const project = { files: { "lib/value.ts": 'export {value} from "archive-value";' }, dependencies: { "archive-value": "1.0.0" }, lock: { + "node_modules/archive-value/package.json": '{"name":"archive-value","version":"1.0.0","main":"index.js","types":"index.d.ts"}', + "node_modules/archive-value/index.js": "export const value = 7;", + "node_modules/archive-value/index.d.ts": "export const value: number;", +} }; + +test("archives validate version, source limits, complete snapshots and safe paths", () => { + const archive = projectArchive("artifact", { name: "portable", source: VALID_ARTIFACT, server_source: serverSource, project }); + expect(parseProjectArchive(JSON.parse(JSON.stringify(archive)))).toEqual(archive); + for (const extra of [{ state: {} }, { secrets: {} }, { access: "public" }]) expect(() => parseProjectArchive({ ...archive, ...extra })).toThrow("archive fields"); + expect(() => parseProjectArchive({ ...archive, version: 2 })).toThrow("Unsupported"); + expect(() => parseProjectArchive({ ...archive, source: "x".repeat(256 * 1024 + 1) })).toThrow("256 KiB"); + expect(() => parseProjectArchive({ ...archive, kind: "script" })).toThrow("server source"); + expect(() => parseProjectArchive({ ...archive, project: { files: {}, dependencies: {} } })).toThrow("complete dependency snapshot"); + expect(() => parseProjectArchive({ ...archive, project: { ...project, files: { "../escape.ts": "bad" } } })).toThrow("invalid project file"); + expect(() => parseProjectArchive({ ...archive, project: { ...project, lock: { "node_modules/../escape.js": "bad" } } })).toThrow("invalid dependency lock"); +}); + +test("filesystem import/export keeps backend and exact dependencies through history, remix and restore", async () => { + const directory = tempDir(); + const service = new ArtifactService({ artifactsDir: directory, cwd: directory, env: { ARTIFACTS_HISTORY_DB: join(directory, "history.sqlite") } }); + const source = 'import {value} from "./lib/value"; export default function App() { return
{value}
; }'; + const archive = projectArchive("artifact", { name: "portable", source, server_source: serverSource, project }); + const imported = service.importProject("fresh", archive); + expect(imported.ok).toBe(true); + expect(imported.runtime_notice).toContain("do not execute"); + const exported = service.exportProject({ name: "fresh" }); + expect(exported).toEqual({ ...archive, name: "fresh" }); + expect(existsSync(imported.path.replace(/\.tsx$/, ".data.json"))).toBe(false); + expect((await service.compile("fresh")).ok).toBe(true); + const original = service.history("fresh")[0]!; + writeFileSync(localServerPath(imported.path), "changed backend"); + service.remix({ name: "fresh", new_name: "remixed" }); + expect(service.exportProject({ name: "remixed" }).server_source).toBe("changed backend"); + expect(service.exportProject({ version_id: original.version_id }).server_source).toBe(serverSource); + service.restore(original.version_id); + expect(service.exportProject({ name: "fresh" })).toEqual(exported); + expect(() => service.importProject("fresh", archive)).toThrow("already exists"); + unlinkSync(imported.path); + expect(() => service.importProject("fresh", archive)).toThrow("history"); + writeFileSync(join(directory, "orphan.artifact.data.json"), "{}"); + expect(() => service.importProject("orphan", archive)).toThrow("state already exists"); + symlinkSync(join(directory, "missing"), localProjectPath(join(directory, "symlink.artifact.tsx"))); + expect(() => service.importProject("symlink", archive)).toThrow("already exists"); + expect(service.exportProject({ version_id: original.version_id }).project.lock).toEqual(project.lock); +}); + +test("filesystem script archives round trip without executing or resolving dependencies", () => { + const directory = tempDir(); + const service = new ArtifactService({ artifactsDir: directory, cwd: directory, env: { ARTIFACTS_HISTORY_DB: join(directory, "history.sqlite") } }); + const archive = projectArchive("script", { name: "handler", source: 'throw new Error("must not execute during filesystem import");', project }); + const imported = service.importProject("new-handler", archive); + expect(imported).toMatchObject({ ok: true, imported: true, kind: "script" }); + expect(service.exportProject({ name: "new-handler", kind: "script" })).toEqual({ ...archive, name: "new-handler" }); + expect(() => service.importProject("new-handler", archive)).toThrow("already exists"); +}); diff --git a/src/project-archive.ts b/src/project-archive.ts new file mode 100644 index 0000000..546a59f --- /dev/null +++ b/src/project-archive.ts @@ -0,0 +1,24 @@ +import { normalizeProject } from "../cloudflare/project"; +import { MAX_PROJECT_ARCHIVE_BYTES, PROJECT_ARCHIVE_FORMAT, type ProjectArchive } from "./project-archive-contract"; + +const bytes = (value: string) => new TextEncoder().encode(value).byteLength; +/** Archives contain code, never storage identities, live data, credentials or URL policy. */ +export function parseProjectArchive(value: unknown, kind?: ProjectArchive["kind"]): ProjectArchive { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Project archive must be an object"); + const archive = value as Record; + if (Object.keys(archive).length !== 7 || Object.keys(archive).some(key => !["format", "version", "kind", "name", "source", "server_source", "project"].includes(key))) throw new Error("Unknown or missing project archive fields"); + if (archive.format !== PROJECT_ARCHIVE_FORMAT || archive.version !== 1) throw new Error("Unsupported project archive format or version"); + if (archive.kind !== "artifact" && archive.kind !== "script" || kind && archive.kind !== kind) throw new Error("Project archive kind does not match this import"); + if (typeof archive.name !== "string" || !archive.name.trim() || bytes(archive.name) > 255 || /[/\\\0]/.test(archive.name)) throw new Error("Invalid project archive name"); + if (typeof archive.source !== "string" || bytes(archive.source) > 256 * 1024 || archive.source.includes("\0")) throw new Error("Project entrypoint exceeds 256 KiB or is invalid"); + if (archive.server_source !== null && (typeof archive.server_source !== "string" || bytes(archive.server_source) > 256 * 1024 || archive.server_source.includes("\0"))) throw new Error("Invalid project server source"); + if (archive.kind === "script" && archive.server_source !== null) throw new Error("Script archives cannot contain artifact server source"); + if (!archive.project || typeof archive.project !== "object" || Array.isArray(archive.project) || !["files", "dependencies", "lock"].every(key => Object.hasOwn(archive.project!, key))) throw new Error("Project archive is missing its complete dependency snapshot"); + const project = normalizeProject(archive.project); + const result = { format: PROJECT_ARCHIVE_FORMAT, version: 1, kind: archive.kind, name: archive.name, source: archive.source, server_source: archive.server_source, project } as ProjectArchive; + if (bytes(JSON.stringify(result)) > MAX_PROJECT_ARCHIVE_BYTES) throw new Error("Project archive exceeds 10 MiB"); + return result; +} +export function projectArchive(kind: ProjectArchive["kind"], snapshot: { name: string; source: string; server_source?: string | null; project?: unknown }): ProjectArchive { + return parseProjectArchive({ format: PROJECT_ARCHIVE_FORMAT, version: 1, kind, name: snapshot.name, source: snapshot.source, server_source: snapshot.server_source ?? null, project: normalizeProject(snapshot.project) }); +} diff --git a/src/serve.ts b/src/serve.ts index 59316e1..776a7be 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -8,6 +8,8 @@ import { assertRegularArtifact, ensureArtifactFileName } from "./artifactFile"; import { compileArtifact } from "./compile"; import { ArtifactHistory, hash, historyPath, runtimeIdentity, type Version } from "./history"; import { ArtifactService } from "./service"; +import { parseProjectArchive, projectArchive } from "./project-archive"; +import { readToolBody } from "../cloudflare/http"; import { artifactHtml } from "./html"; import { galleryBundle, galleryData, galleryHtml, workingSource } from "./gallery/server"; @@ -94,12 +96,19 @@ export async function createArtifactServer(opts: CreateArtifactServerOptions): P return json({ ok: false, error: "same-origin JSON request required" }, 403); } const workspace = url.searchParams.get("workspace"); - if (workspace && resolve(workspace) !== artifactsDir) return json({ ok: false, error: "open this workspace's gallery to remix its artifact" }, 400); - const body = await request.json() as { name?: string; arguments?: Parameters[0] }; - if (body.name !== "artifact_remix" || !body.arguments) return json({ ok: false, error: "unknown tool" }, 400); + if (workspace && resolve(workspace) !== artifactsDir) return json({ ok: false, error: "open this workspace's gallery to import or remix its artifact" }, 400); + let body: { name?: string; arguments?: Record }; + try { body = await readToolBody(request) as typeof body; } + catch (error) { return json({ error: error instanceof Error ? error.message : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } + if (!body || !["artifact_remix", "artifact_import"].includes(body.name ?? "") || !body.arguments) return json({ ok: false, error: "unknown tool" }, 400); try { const service = new ArtifactService({ artifactsDir, env: { ...env, ARTIFACTS_HISTORY_DB: history.path } }); - return json(service.remix(body.arguments)); + if (body.name === "artifact_import") { + const archive = parseProjectArchive(body.arguments.archive, "artifact"); + const result = service.importProject(body.arguments.new_name as string, archive); + return json({ structuredContent: result, isError: !result.ok }); + } + return json(service.remix(body.arguments as Parameters[0])); } catch (error) { return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400); } } @@ -124,6 +133,10 @@ export async function createArtifactServer(opts: CreateArtifactServerOptions): P source = selected.source; sourcePath = selected.path; artifactName = selected.name; } catch { return new Response("working artifact not found or not a regular file", { status: 404 }); } if (url.pathname === "/api/source") { + if (url.searchParams.get("format") === "project") { + const archive = version ? projectArchive("artifact", version) : new ArtifactService({ artifactsDir, env: { ...env, ARTIFACTS_HISTORY_DB: history.path } }).exportProject({ name: artifactName }); + return new Response(JSON.stringify(archive), { headers: { "content-type": "application/json", "cache-control": "no-store", "content-disposition": `attachment; filename="project.artifact-project.json"; filename*=UTF-8''${encodeURIComponent(artifactName + ".artifact-project.json")}` } }); + } return new Response(source, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...(url.searchParams.get("download") === "1" ? { "content-disposition": `attachment; filename="artifact.artifact.tsx"; filename*=UTF-8''${encodeURIComponent(artifactName + ".artifact.tsx")}` } : {}), diff --git a/src/service.ts b/src/service.ts index a03258f..5fa0d5b 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,7 +1,9 @@ import { createRequire } from "node:module"; import { join } from "node:path"; import { PLUGIN_ROOT } from "./paths"; -import { readLocalProject, writeLocalProject, localProjectPath } from "./localProject"; +import { readLocalProject, writeLocalProject, localProjectPath, localServerPath, readLocalServer, writeLocalServer } from "./localProject"; +import { parseProjectArchive, projectArchive } from "./project-archive"; +import type { ProjectArchive } from "./project-archive-contract"; import { resolveProject } from "../cloudflare/project"; import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; @@ -191,6 +193,55 @@ export class ArtifactService { finally { archive.close(); } } + exportProject(input: { name?: string; version_id?: string; kind?: ProjectArchive["kind"] }): ProjectArchive { + if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); + const kind = input.kind ?? "artifact"; + if (input.version_id) { + if (kind !== "artifact") throw new Error("Filesystem script archives do not have source history"); + return projectArchive(kind, this.version(input.version_id)); + } + if (kind !== "artifact" && kind !== "script") throw new Error("kind must be artifact or script"); + const name = input.name!; + const path = kind === "artifact" ? this.resolve(ensureArtifactFileName(name)) : this.scriptPath(name); + assertRegularArtifact(path); + return projectArchive(kind, { name: kind === "artifact" ? artifactIdFromFile(path) : basename(path, ".script.ts"), source: readFileSync(path, "utf8"), server_source: kind === "artifact" ? readLocalServer(path) : null, project: readLocalProject(path) }); + } + + private scriptPath(name: string): string { + if (typeof name !== "string" || !name.trim() || /[/\\\0]/.test(name) || name === "." || name === "..") throw new Error("Invalid script name"); + const filename = name.endsWith(".script.ts") ? name : `${name}.script.ts`; + if (new TextEncoder().encode(filename).byteLength > 255) throw new Error("Script name is too long"); + return join(this.artifactsDir, filename); + } + + importProject(newName: string, input: unknown) { + const archive = parseProjectArchive(input); + const path = archive.kind === "artifact" ? join(this.artifactsDir, ensureArtifactFileName(newName)) : this.scriptPath(newName); + const name = archive.kind === "artifact" ? artifactIdFromFile(path) : basename(path, ".script.ts"); + const history = new ArtifactHistory(historyPath(this.env)); + try { + history.db.transaction(() => { + if (history.db.query("select id from artifacts where workspace=? and name=?").get(resolve(this.artifactsDir), name)) throw new Error("Destination already exists in history; choose a new name"); + const candidates = [join(this.artifactsDir, `${name}.artifact.tsx`), join(this.artifactsDir, `${name}.canvas.tsx`), join(this.artifactsDir, `${name}.script.ts`)]; + for (const candidate of candidates.flatMap(source => [source, localProjectPath(source), source.replace(/\.tsx$/, ".data.json"), ...(source.endsWith(".tsx") ? [localServerPath(source)] : [])])) { + try { lstatSync(candidate); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } + throw new Error("Destination source or state already exists; choose a new name"); + } + ensureArtifactsDir(this.artifactsDir); + const created: string[] = []; + try { + writeLocalProject(path, archive.project, true); created.push(localProjectPath(path)); + if (archive.server_source !== null) { writeLocalServer(path, archive.server_source, true); created.push(localServerPath(path)); } + writeFileSync(path, archive.source, { flag: "wx" }); created.push(path); + if (archive.kind === "artifact") history.capture({ workspace: this.artifactsDir, name, sourcePath: path, source: archive.source, server_source: archive.server_source, project: archive.project, runtime: runtimeIdentity(), reason: "import" }); + } catch (error) { for (const file of created.reverse()) unlinkSync(file); throw error; } + }).immediate(); + const diagnostics = archive.kind === "artifact" ? typecheckArtifact(path) : []; + return { ok: diagnostics.length === 0, applied: true, imported: true, name, kind: archive.kind, path, check: archive.kind === "artifact" ? formatArtifactCheck(diagnostics) : "Script source imported; a hosted runtime is required to validate and execute it.", diagnostics, + ...(archive.kind === "script" || archive.server_source !== null ? { runtime_notice: "Filesystem previews do not execute server code or scripts. Deploy this archive in a hosted runtime to validate and run them." } : {}) }; + } finally { history.close(); } + } + version(id: string) { const archive = new ArtifactHistory(historyPath(this.env)); try { @@ -215,7 +266,7 @@ export class ArtifactService { } // Reject orphaned state and dangling links too: a remix always starts fresh. const destinations = [join(this.artifactsDir, `${name}.artifact.tsx`), join(this.artifactsDir, `${name}.canvas.tsx`)]; - for (const candidate of destinations.flatMap(source => [source, localProjectPath(source), source.replace(/\.tsx$/, ".data.json")])) { + for (const candidate of destinations.flatMap(source => [source, localProjectPath(source), localServerPath(source), source.replace(/\.tsx$/, ".data.json")])) { try { lstatSync(candidate); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } throw new Error("destination artifact or state already exists; choose a new name"); @@ -233,13 +284,16 @@ export class ArtifactService { source: readFileSync(sourcePath, "utf8"), runtime, reason: "before-remix" }); } const version = archive.capture({ workspace: this.artifactsDir, name, sourcePath: path, - source: sourceVersion.source, project: sourceVersion.project, runtime, reason: "remix" }); + source: sourceVersion.source, server_source: sourceVersion.server_source ?? null, project: sourceVersion.project, runtime, reason: "remix" }); archive.db.query("insert into artifact_remixes values (?, ?)").run(version.artifact_id, sourceVersion.id); ensureArtifactsDir(this.artifactsDir); // Exclusive creation never follows or replaces a competing destination link/file. writeLocalProject(path, sourceVersion.project!, true); - try { writeFileSync(path, sourceVersion.source, { flag: "wx" }); } - catch (error) { unlinkSync(localProjectPath(path)); throw error; } + let serverCreated = false; + try { + if (sourceVersion.server_source != null) { writeLocalServer(path, sourceVersion.server_source, true); serverCreated = true; } + writeFileSync(path, sourceVersion.source, { flag: "wx" }); + } catch (error) { if (serverCreated) unlinkSync(localServerPath(path)); unlinkSync(localProjectPath(path)); throw error; } return version; }).immediate(); const diagnostics = typecheckArtifact(path); @@ -269,6 +323,7 @@ export class ArtifactService { ensureArtifactsDir(this.artifactsDir); replaceArtifactSource(path, target.source, current); writeLocalProject(path, target.project!); + writeLocalServer(path, target.server_source ?? null); const revision = archive.capture({ workspace: this.artifactsDir, name: target.name, sourcePath: path, source: target.source, runtime, reason: "restore", restoredFrom: id, force: true }); const diagnostics = typecheckArtifact(path); return { ok: diagnostics.length === 0, restored: true, path, versionId: revision.id, revision: revision.revision, check: formatArtifactCheck(diagnostics), diagnostics };