Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions cloudflare/http.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<string> {
if (!request.body) return "";
const reader = request.body.getReader();
Expand Down
17 changes: 17 additions & 0 deletions cloudflare/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ export class ArtifactLibrary extends DurableObject<unknown> {
});
}

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");
Expand Down
13 changes: 9 additions & 4 deletions cloudflare/managed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 13 additions & 5 deletions cloudflare/script-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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]));
Expand All @@ -76,18 +81,20 @@ 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);
}
// 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;
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions cloudflare/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export class ScriptLibrary extends DurableObject<unknown> {
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}));
Expand Down
14 changes: 14 additions & 0 deletions cloudflare/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions cloudflare/tool-contract.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand Down Expand Up @@ -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<string, object>, required: string[] = []) {
CLOUD_MCP_TOOLS.push({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } });
}
Expand Down
Loading
Loading