diff --git a/api/app.ts b/api/app.ts index 226749b..249345b 100644 --- a/api/app.ts +++ b/api/app.ts @@ -1,5 +1,10 @@ import { withRuntime } from "@decocms/runtime"; import { createAssetsAppResource } from "./resources/assets.ts"; +import { createCfbBuildVarsAppResource } from "./resources/cfb-build-vars.ts"; +import { createCfbBuildsAppResource } from "./resources/cfb-builds.ts"; +import { createCfbSecretsAppResource } from "./resources/cfb-secrets.ts"; +import { createCfbSetupAppResource } from "./resources/cfb-setup.ts"; +import { createCfbVersionsAppResource } from "./resources/cfb-versions.ts"; import { createEnvironmentsAppResource } from "./resources/environments.ts"; import { createFileExplorerAppResource } from "./resources/file-explorer.ts"; import { createIssuesAppResource } from "./resources/issues.ts"; @@ -118,6 +123,11 @@ export function createApp(opts: CreateAppOptions): Fetcher { createPullRequestsAppResource(getClientHTML), createReleasesAppResource(getClientHTML), createRenderHtmlAppResource(getClientHTML), + createCfbSetupAppResource(getClientHTML), + createCfbSecretsAppResource(getClientHTML), + createCfbBuildVarsAppResource(getClientHTML), + createCfbBuildsAppResource(getClientHTML), + createCfbVersionsAppResource(getClientHTML), ], }); diff --git a/api/resources/cfb-build-vars.ts b/api/resources/cfb-build-vars.ts new file mode 100644 index 0000000..ae307a9 --- /dev/null +++ b/api/resources/cfb-build-vars.ts @@ -0,0 +1,23 @@ +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_BUILD_VARS_RESOURCE_URI } from "../tools/cfb-build-vars.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const createCfbBuildVarsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: CFB_BUILD_VARS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Build Vars", + description: + "Manage build-time variables on the Cloudflare production trigger for the configured site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: CFB_BUILD_VARS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/cfb-builds.ts b/api/resources/cfb-builds.ts new file mode 100644 index 0000000..06673e3 --- /dev/null +++ b/api/resources/cfb-builds.ts @@ -0,0 +1,23 @@ +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_BUILDS_RESOURCE_URI } from "../tools/cfb-builds.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const createCfbBuildsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: CFB_BUILDS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Builds", + description: + "List recent Cloudflare Workers Builds, view per-build logs, and trigger new builds.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: CFB_BUILDS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/cfb-secrets.ts b/api/resources/cfb-secrets.ts new file mode 100644 index 0000000..a0116c8 --- /dev/null +++ b/api/resources/cfb-secrets.ts @@ -0,0 +1,23 @@ +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_SECRETS_RESOURCE_URI } from "../tools/cfb-secrets.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const createCfbSecretsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: CFB_SECRETS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Secrets", + description: + "Manage runtime secrets bound to the Cloudflare Worker for the configured site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: CFB_SECRETS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/cfb-setup.ts b/api/resources/cfb-setup.ts new file mode 100644 index 0000000..664337e --- /dev/null +++ b/api/resources/cfb-setup.ts @@ -0,0 +1,23 @@ +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_SETUP_RESOURCE_URI } from "../tools/cfb-setup.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const createCfbSetupAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: CFB_SETUP_RESOURCE_URI, + name: "Cloudflare Workers Builds — Setup", + description: + "One-click onboarding and setup status for a Cloudflare Workers Builds-hosted site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: CFB_SETUP_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/cfb-versions.ts b/api/resources/cfb-versions.ts new file mode 100644 index 0000000..bd292b2 --- /dev/null +++ b/api/resources/cfb-versions.ts @@ -0,0 +1,23 @@ +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_VERSIONS_RESOURCE_URI } from "../tools/cfb-versions.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const createCfbVersionsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: CFB_VERSIONS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Versions", + description: + "List recent Cloudflare Worker versions and roll back to a previous version.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: CFB_VERSIONS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/tools/cfb-build-vars.ts b/api/tools/cfb-build-vars.ts new file mode 100644 index 0000000..b29fcc1 --- /dev/null +++ b/api/tools/cfb-build-vars.ts @@ -0,0 +1,133 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_BUILD_VARS_RESOURCE_URI = "ui://mcp-app/cfb-build-vars"; + +const NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; +const NAME_HINT = + "Letters, digits, and underscore only; must start with a letter or underscore; max 64 chars."; + +export const buildVarsResultSchema = z.object({ + triggerUuid: z.string(), + buildVars: z.record(z.string(), z.string()), +}); +export type BuildVarsResult = z.infer; + +// ─── cfb_list_build_vars ────────────────────────────────────────────────────── + +export const cfbListBuildVarsInputSchema = z.object({}); +export type CfbListBuildVarsInput = z.infer; + +export const cfbListBuildVarsOutputSchema = buildVarsResultSchema; +export type CfbListBuildVarsOutput = z.infer< + typeof cfbListBuildVarsOutputSchema +>; + +export const cfbListBuildVarsTool = createTool({ + id: "cfb_list_build_vars", + description: + "List Cloudflare build-time variables for the configured site's production trigger. Build vars are visible to anyone with access to the site — for secrets that must stay hidden, use `cfb_set_secret` instead.", + inputSchema: cfbListBuildVarsInputSchema, + outputSchema: cfbListBuildVarsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/vars/list.ts", + { sitename: site }, + apiKey, + )) as CfbListBuildVarsOutput; + }, +}); + +// ─── cfb_set_build_var ──────────────────────────────────────────────────────── + +export const cfbSetBuildVarInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe(`Build var name. ${NAME_HINT}`), + value: z + .string() + .describe("Build var value (plain text, visible after set)."), +}); +export type CfbSetBuildVarInput = z.infer; + +export const cfbSetBuildVarOutputSchema = buildVarsResultSchema; +export type CfbSetBuildVarOutput = z.infer; + +export const cfbSetBuildVarTool = createTool({ + id: "cfb_set_build_var", + description: + "Create or update a Cloudflare build-time variable on the configured site's production trigger. Overwrites the value if the name already exists. These are NOT runtime secrets — values are plain-text and visible.", + inputSchema: cfbSetBuildVarInputSchema, + outputSchema: cfbSetBuildVarOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/vars/set.ts", + { sitename: site, name: context.name, value: context.value }, + apiKey, + )) as CfbSetBuildVarOutput; + }, +}); + +// ─── cfb_delete_build_var ───────────────────────────────────────────────────── + +export const cfbDeleteBuildVarInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe("Build var name to remove."), +}); +export type CfbDeleteBuildVarInput = z.infer< + typeof cfbDeleteBuildVarInputSchema +>; + +export const cfbDeleteBuildVarOutputSchema = buildVarsResultSchema; +export type CfbDeleteBuildVarOutput = z.infer< + typeof cfbDeleteBuildVarOutputSchema +>; + +export const cfbDeleteBuildVarTool = createTool({ + id: "cfb_delete_build_var", + description: + "Remove a Cloudflare build-time variable from the configured site's production trigger.", + inputSchema: cfbDeleteBuildVarInputSchema, + outputSchema: cfbDeleteBuildVarOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/vars/delete.ts", + { sitename: site, name: context.name }, + apiKey, + )) as CfbDeleteBuildVarOutput; + }, +}); diff --git a/api/tools/cfb-builds.ts b/api/tools/cfb-builds.ts new file mode 100644 index 0000000..8ebc00e --- /dev/null +++ b/api/tools/cfb-builds.ts @@ -0,0 +1,209 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_BUILDS_RESOURCE_URI = "ui://mcp-app/cfb-builds"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const cfBuildStatusSchema = z.enum([ + "queued", + "initializing", + "running", + "success", + "failure", + "cancelled", +]); +export type CfBuildStatus = z.infer; + +export const cfBuildSchema = z + .object({ + build_uuid: z.string(), + build_id: z.string().optional(), + external_script_id: z.string(), + status: cfBuildStatusSchema, + branch: z.string().optional(), + commit_hash: z.string().optional(), + commit_message: z.string().optional(), + created_on: z.string(), + modified_on: z.string().optional(), + build_duration_ms: z.number().optional(), + trigger: z + .object({ + source: z.string(), + trigger_uuid: z.string().optional(), + }) + .optional(), + }) + .passthrough(); +export type CfBuild = z.infer; + +export const cfBuildLogChunkSchema = z + .object({ + line: z.number(), + message: z.string(), + timestamp: z.string().optional(), + stream: z.enum(["stdout", "stderr"]).optional(), + }) + .passthrough(); +export type CfBuildLogChunk = z.infer; + +// ─── cfb_list_builds ────────────────────────────────────────────────────────── + +export const cfbListBuildsInputSchema = z.object({ + pageSize: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum number of builds to return (default: 25)."), +}); +export type CfbListBuildsInput = z.infer; + +export const cfbListBuildsOutputSchema = z.object({ + builds: z.array(cfBuildSchema), +}); +export type CfbListBuildsOutput = z.infer; + +export const cfbListBuildsTool = createTool({ + id: "cfb_list_builds", + description: + "List recent Cloudflare Workers Builds for the configured site, newest first. Returns build id, status, branch, commit, and duration.", + inputSchema: cfbListBuildsInputSchema, + outputSchema: cfbListBuildsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + const raw = await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/list.ts", + { + sitename: site, + ...(context.pageSize ? { pageSize: context.pageSize } : {}), + }, + apiKey, + ); + // Defensive: admin should return CFBuild[] but the CF Builds list endpoint + // sometimes wraps results as `{ items: [...] }` or `{ builds: [...] }`, + // and admin doesn't always unwrap. Normalize to an array here. + const builds: CfBuild[] = Array.isArray(raw) + ? (raw as CfBuild[]) + : ((raw as { items?: CfBuild[]; builds?: CfBuild[] })?.items ?? + (raw as { items?: CfBuild[]; builds?: CfBuild[] })?.builds ?? + []); + return { builds }; + }, +}); + +// ─── cfb_get_build ──────────────────────────────────────────────────────────── + +export const cfbGetBuildInputSchema = z.object({ + buildId: z.string().describe("The Cloudflare build UUID."), +}); +export type CfbGetBuildInput = z.infer; + +export const cfbGetBuildOutputSchema = cfBuildSchema; +export type CfbGetBuildOutput = z.infer; + +export const cfbGetBuildTool = createTool({ + id: "cfb_get_build", + description: + "Fetch details for a single Cloudflare Workers build by UUID. Use this to refresh the status of a running build without re-listing.", + inputSchema: cfbGetBuildInputSchema, + outputSchema: cfbGetBuildOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/get.ts", + { sitename: site, buildId: context.buildId }, + apiKey, + )) as CfbGetBuildOutput; + }, +}); + +// ─── cfb_get_build_logs ─────────────────────────────────────────────────────── + +export const cfbGetBuildLogsInputSchema = z.object({ + buildId: z.string().describe("The Cloudflare build UUID."), +}); +export type CfbGetBuildLogsInput = z.infer; + +export const cfbGetBuildLogsOutputSchema = z.object({ + lines: z.array(cfBuildLogChunkSchema), +}); +export type CfbGetBuildLogsOutput = z.infer; + +export const cfbGetBuildLogsTool = createTool({ + id: "cfb_get_build_logs", + description: + "Fetch the full log output for a Cloudflare Workers build by UUID. Cloudflare returns the logs as a single blob; the UI paginates client-side for large logs.", + inputSchema: cfbGetBuildLogsInputSchema, + outputSchema: cfbGetBuildLogsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/logs.ts", + { sitename: site, buildId: context.buildId }, + apiKey, + )) as CfbGetBuildLogsOutput; + }, +}); + +// ─── cfb_trigger_build ──────────────────────────────────────────────────────── + +export const cfbTriggerBuildInputSchema = z.object({}); +export type CfbTriggerBuildInput = z.infer; + +export const cfbTriggerBuildOutputSchema = cfBuildSchema; +export type CfbTriggerBuildOutput = z.infer; + +export const cfbTriggerBuildTool = createTool({ + id: "cfb_trigger_build", + description: + "Manually trigger a new Cloudflare Workers production build for the configured site. Returns the new build's metadata so the UI can poll its status.", + inputSchema: cfbTriggerBuildInputSchema, + outputSchema: cfbTriggerBuildOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/builds/trigger.ts", + { sitename: site }, + apiKey, + )) as CfbTriggerBuildOutput; + }, +}); diff --git a/api/tools/cfb-secrets.ts b/api/tools/cfb-secrets.ts new file mode 100644 index 0000000..def4e12 --- /dev/null +++ b/api/tools/cfb-secrets.ts @@ -0,0 +1,146 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_SECRETS_RESOURCE_URI = "ui://mcp-app/cfb-secrets"; + +// CF: secret/var names must match this regex. +const NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; +const NAME_HINT = + "Letters, digits, and underscore only; must start with a letter or underscore; max 64 chars."; + +export const workerSecretTypeSchema = z.enum(["secret_text", "secret_key"]); +export type WorkerSecretType = z.infer; + +export const workerSecretBindingSchema = z.object({ + name: z.string(), + type: workerSecretTypeSchema, +}); +export type WorkerSecretBinding = z.infer; + +// ─── cfb_list_secrets ───────────────────────────────────────────────────────── + +export const cfbListSecretsInputSchema = z.object({}); +export type CfbListSecretsInput = z.infer; + +export const cfbListSecretsOutputSchema = z.object({ + secrets: z.array(workerSecretBindingSchema), +}); +export type CfbListSecretsOutput = z.infer; + +export const cfbListSecretsTool = createTool({ + id: "cfb_list_secrets", + description: + "List Cloudflare runtime secret names bound to the Worker for the configured site. Cloudflare never returns secret values — only names and types.", + inputSchema: cfbListSecretsInputSchema, + outputSchema: cfbListSecretsOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + const secrets = (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/secrets/list.ts", + { sitename: site }, + apiKey, + )) as WorkerSecretBinding[]; + return { secrets }; + }, +}); + +// ─── cfb_set_secret ─────────────────────────────────────────────────────────── + +export const cfbSetSecretInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe(`Secret name. ${NAME_HINT}`), + value: z.string().describe("Secret value (will be PUT to Cloudflare)."), + type: workerSecretTypeSchema + .optional() + .describe( + "Cloudflare secret type. `secret_text` (default) for plain strings; `secret_key` for cryptographic key material.", + ), +}); +export type CfbSetSecretInput = z.infer; + +export const cfbSetSecretOutputSchema = workerSecretBindingSchema; +export type CfbSetSecretOutput = z.infer; + +export const cfbSetSecretTool = createTool({ + id: "cfb_set_secret", + description: + "Create or update a Cloudflare runtime secret on the Worker for the configured site. Overwrites the value if a secret with the same name already exists.", + inputSchema: cfbSetSecretInputSchema, + outputSchema: cfbSetSecretOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/secrets/set.ts", + { + sitename: site, + name: context.name, + value: context.value, + ...(context.type ? { type: context.type } : {}), + }, + apiKey, + )) as CfbSetSecretOutput; + }, +}); + +// ─── cfb_delete_secret ──────────────────────────────────────────────────────── + +export const cfbDeleteSecretInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe("Secret name to remove."), +}); +export type CfbDeleteSecretInput = z.infer; + +export const cfbDeleteSecretOutputSchema = z.object({ + ok: z.literal(true), + name: z.string(), +}); +export type CfbDeleteSecretOutput = z.infer; + +export const cfbDeleteSecretTool = createTool({ + id: "cfb_delete_secret", + description: + "Permanently remove a Cloudflare runtime secret from the Worker for the configured site.", + inputSchema: cfbDeleteSecretInputSchema, + outputSchema: cfbDeleteSecretOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/secrets/delete.ts", + { sitename: site, name: context.name }, + apiKey, + ); + return { ok: true as const, name: context.name }; + }, +}); diff --git a/api/tools/cfb-setup.ts b/api/tools/cfb-setup.ts new file mode 100644 index 0000000..d5dedea --- /dev/null +++ b/api/tools/cfb-setup.ts @@ -0,0 +1,136 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; +import { cfBuildSchema } from "./cfb-builds.ts"; + +export const CFB_SETUP_RESOURCE_URI = "ui://mcp-app/cfb-setup"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const setupStepSchema = z.enum([ + "load_site", + "resolve_repo", + "connect_repo", + "ensure_worker", + "check_worker_collision", + "create_prod_trigger", + "create_preview_trigger", + "audit", +]); +export type SetupStep = z.infer; + +export const setupErrorSchema = z.object({ + step: setupStepSchema, + code: z.string(), + cfErrorCode: z.number().optional(), + message: z.string(), + hint: z.string().optional(), +}); +export type SetupError = z.infer; + +// ─── cfb_setup ──────────────────────────────────────────────────────────────── + +export const cfbSetupInputSchema = z.object({ + rootDirectory: z + .string() + .optional() + .describe( + "Root directory inside the repo to build from (defaults to repo root).", + ), + buildCommand: z + .string() + .optional() + .describe("Custom build command (defaults to the repo's wrangler config)."), + deployCommand: z + .string() + .optional() + .describe("Custom deploy command (defaults to `wrangler deploy`)."), +}); +export type CfbSetupInput = z.infer; + +export const cfbSetupOutputSchema = z.object({ + ok: z.boolean(), + workerName: z.string(), + workerExists: z.boolean(), + workerTag: z.string().nullable(), + repoConnectionId: z.string().nullable(), + prodTriggerUuid: z.string().nullable(), + previewTriggerUuid: z.string().nullable(), + errors: z.array(setupErrorSchema), +}); +export type CfbSetupOutput = z.infer; + +export const cfbSetupTool = createTool({ + id: "cfb_setup", + description: + "One-click Cloudflare Workers Builds onboarding for the configured site: connects the GitHub repo, ensures the Worker script exists, and creates the production + preview build triggers. Idempotent — safe to re-run; missing steps will be filled in and per-step errors are surfaced in `errors[]`.", + inputSchema: cfbSetupInputSchema, + outputSchema: cfbSetupOutputSchema, + _meta: { + ui: { resourceUri: CFB_SETUP_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/setup/oneClick.ts", + { + sitename: site, + ...(context.rootDirectory + ? { rootDirectory: context.rootDirectory } + : {}), + ...(context.buildCommand ? { buildCommand: context.buildCommand } : {}), + ...(context.deployCommand + ? { deployCommand: context.deployCommand } + : {}), + }, + apiKey, + )) as CfbSetupOutput; + }, +}); + +// ─── cfb_setup_status ───────────────────────────────────────────────────────── + +export const cfbSetupStatusInputSchema = z.object({}); +export type CfbSetupStatusInput = z.infer; + +export const cfbSetupStatusOutputSchema = z.object({ + workerName: z.string(), + workerExists: z.boolean(), + workerTag: z.string().nullable(), + repoConnected: z.boolean(), + prodTrigger: z.object({ trigger_uuid: z.string() }).nullable(), + previewTrigger: z.object({ trigger_uuid: z.string() }).nullable(), + lastBuild: cfBuildSchema.nullable(), +}); +export type CfbSetupStatusOutput = z.infer; + +export const cfbSetupStatusTool = createTool({ + id: "cfb_setup_status", + description: + "Read-only snapshot of the Cloudflare Workers Builds setup for the configured site: whether the repo is connected, the Worker exists, the prod/preview triggers exist, and the most recent build. Safe to call before any setup has happened.", + inputSchema: cfbSetupStatusInputSchema, + outputSchema: cfbSetupStatusOutputSchema, + _meta: { + ui: { resourceUri: CFB_SETUP_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/setup/status.ts", + { sitename: site }, + apiKey, + )) as CfbSetupStatusOutput; + }, +}); diff --git a/api/tools/cfb-versions.ts b/api/tools/cfb-versions.ts new file mode 100644 index 0000000..ddaa849 --- /dev/null +++ b/api/tools/cfb-versions.ts @@ -0,0 +1,132 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_VERSIONS_RESOURCE_URI = "ui://mcp-app/cfb-versions"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const workerVersionSchema = z + .object({ + id: z.string(), + number: z.number().optional(), + created_on: z.string().optional(), + metadata: z + .object({ + has_assets: z.boolean().optional(), + has_modules: z.boolean().optional(), + annotations: z.record(z.string(), z.string()).optional(), + }) + .passthrough() + .optional(), + resources: z + .object({ + bindings: z.array(z.record(z.string(), z.unknown())).optional(), + script: z + .object({ etag: z.string().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +export type WorkerVersion = z.infer; + +export const versionRowSchema = workerVersionSchema.and( + z.object({ isActive: z.boolean() }), +); +export type VersionRow = z.infer; + +export const workerDeploymentSchema = z + .object({ + id: z.string(), + source: z.string().optional(), + strategy: z.string().optional(), + versions: z.array( + z.object({ version_id: z.string(), percentage: z.number() }), + ), + annotations: z.record(z.string(), z.string()).optional(), + created_on: z.string().optional(), + author_email: z.string().optional(), + }) + .passthrough(); +export type WorkerDeployment = z.infer; + +// ─── cfb_list_versions ──────────────────────────────────────────────────────── + +export const cfbListVersionsInputSchema = z.object({}); +export type CfbListVersionsInput = z.infer; + +export const cfbListVersionsOutputSchema = z.object({ + versions: z.array(versionRowSchema), +}); +export type CfbListVersionsOutput = z.infer; + +export const cfbListVersionsTool = createTool({ + id: "cfb_list_versions", + description: + "List the most recent Cloudflare Worker versions for the configured site (last 100). Each row is flagged `isActive` if it receives any traffic in the current deployment.", + inputSchema: cfbListVersionsInputSchema, + outputSchema: cfbListVersionsOutputSchema, + _meta: { + ui: { resourceUri: CFB_VERSIONS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + const versions = (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/versions/list.ts", + { sitename: site }, + apiKey, + )) as VersionRow[]; + return { versions }; + }, +}); + +// ─── cfb_rollback ───────────────────────────────────────────────────────────── + +export const cfbRollbackInputSchema = z.object({ + versionId: z + .string() + .describe( + "The Worker version id to roll back to (must be in the last 100 versions returned by `cfb_list_versions`).", + ), +}); +export type CfbRollbackInput = z.infer; + +export const cfbRollbackOutputSchema = z.object({ + deployment: workerDeploymentSchema, + warnings: z.array(z.string()), +}); +export type CfbRollbackOutput = z.infer; + +export const cfbRollbackTool = createTool({ + id: "cfb_rollback", + description: + "Roll the Worker for the configured site back to a previous version by creating a new 100% deployment of that version. Best-effort compatibility check warns about binding-shape mismatches; Cloudflare may still reject incompatible rollbacks for D1/R2/KV/DO data shape changes.", + inputSchema: cfbRollbackInputSchema, + outputSchema: cfbRollbackOutputSchema, + _meta: { + ui: { resourceUri: CFB_VERSIONS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/deployments/rollback.ts", + { sitename: site, versionId: context.versionId }, + apiKey, + )) as CfbRollbackOutput; + }, +}); diff --git a/api/tools/index.ts b/api/tools/index.ts index 6db2f69..002e5fd 100644 --- a/api/tools/index.ts +++ b/api/tools/index.ts @@ -1,5 +1,23 @@ import { analyticsQueryTool } from "./analytics-query.ts"; import { assetsTool, deleteAssetTool, uploadAssetTool } from "./assets.ts"; +import { + cfbDeleteBuildVarTool, + cfbListBuildVarsTool, + cfbSetBuildVarTool, +} from "./cfb-build-vars.ts"; +import { + cfbGetBuildLogsTool, + cfbGetBuildTool, + cfbListBuildsTool, + cfbTriggerBuildTool, +} from "./cfb-builds.ts"; +import { + cfbDeleteSecretTool, + cfbListSecretsTool, + cfbSetSecretTool, +} from "./cfb-secrets.ts"; +import { cfbSetupStatusTool, cfbSetupTool } from "./cfb-setup.ts"; +import { cfbListVersionsTool, cfbRollbackTool } from "./cfb-versions.ts"; import { suggestCommitMessageTool } from "./commit-summary.ts"; import { createEnvironmentTool, @@ -127,4 +145,18 @@ export const tools = [ podLogsTool, renderHtmlTool, testLoaderTool, + cfbSetupTool, + cfbSetupStatusTool, + cfbListSecretsTool, + cfbSetSecretTool, + cfbDeleteSecretTool, + cfbListBuildVarsTool, + cfbSetBuildVarTool, + cfbDeleteBuildVarTool, + cfbListBuildsTool, + cfbGetBuildTool, + cfbGetBuildLogsTool, + cfbTriggerBuildTool, + cfbListVersionsTool, + cfbRollbackTool, ]; diff --git a/web/router.tsx b/web/router.tsx index fec5d27..97721db 100644 --- a/web/router.tsx +++ b/web/router.tsx @@ -8,6 +8,11 @@ import { } from "@tanstack/react-router"; import { useMcpHostContext, useMcpState } from "./context.tsx"; import AssetsPage from "./tools/assets/index.tsx"; +import CfbBuildVarsPage from "./tools/cfb-build-vars/index.tsx"; +import CfbBuildsPage from "./tools/cfb-builds/index.tsx"; +import CfbSecretsPage from "./tools/cfb-secrets/index.tsx"; +import CfbSetupPage from "./tools/cfb-setup/index.tsx"; +import CfbVersionsPage from "./tools/cfb-versions/index.tsx"; import FileExplorerPage from "./tools/file-explorer/index.tsx"; import IssuesPage from "./tools/issues/index.tsx"; import MonitorPage from "./tools/monitor/index.tsx"; @@ -23,6 +28,20 @@ const TOOL_PAGES: Record = { list_pull_requests: PullRequestsPage, list_releases: ReleasesPage, render_html: RenderHtmlPage, + cfb_setup: CfbSetupPage, + cfb_setup_status: CfbSetupPage, + cfb_list_secrets: CfbSecretsPage, + cfb_set_secret: CfbSecretsPage, + cfb_delete_secret: CfbSecretsPage, + cfb_list_build_vars: CfbBuildVarsPage, + cfb_set_build_var: CfbBuildVarsPage, + cfb_delete_build_var: CfbBuildVarsPage, + cfb_list_builds: CfbBuildsPage, + cfb_get_build: CfbBuildsPage, + cfb_get_build_logs: CfbBuildsPage, + cfb_trigger_build: CfbBuildsPage, + cfb_list_versions: CfbVersionsPage, + cfb_rollback: CfbVersionsPage, }; function ToolRouter() { diff --git a/web/tools/cfb-build-vars/index.tsx b/web/tools/cfb-build-vars/index.tsx new file mode 100644 index 0000000..132093f --- /dev/null +++ b/web/tools/cfb-build-vars/index.tsx @@ -0,0 +1,175 @@ +import { AlertTriangle, CheckCircle2, Loader2, Variable } from "lucide-react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpState } from "@/context.tsx"; +import type { + BuildVarsResult, + CfbDeleteBuildVarOutput, + CfbListBuildVarsOutput, + CfbSetBuildVarOutput, +} from "../../../api/tools/cfb-build-vars.ts"; + +type AnyOutput = + | CfbListBuildVarsOutput + | CfbSetBuildVarOutput + | CfbDeleteBuildVarOutput; + +function VarsTable({ data }: { data: BuildVarsResult }) { + const entries = Object.entries(data.buildVars); + + return ( + + +
+ + + Build vars ({entries.length}) + + + trigger {data.triggerUuid.slice(0, 8)} + +
+
+ + {entries.length === 0 ? ( +
+ No build vars. Use{" "} + + cfb_set_build_var + {" "} + to add one. +
+ ) : ( + + + + Name + Value + + + + {entries.map(([name, value]) => ( + + {name} + + {value} + + + ))} + +
+ )} +
+
+ ); +} + +export default function CfbBuildVarsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Build vars + + +

+ Call{" "} + + cfb_list_build_vars + {" "} + to view build-time variables. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + const justChanged = + state.toolName === "cfb_set_build_var" || + state.toolName === "cfb_delete_build_var"; + + return ( +
+
+

Build-time variables

+

+ + Values are plain-text and visible to anyone with site access. Use{" "} + cfb_set_secret for + sensitive values. +

+
+ + {justChanged && ( +
+ + {state.toolName === "cfb_set_build_var" + ? "Build var updated." + : "Build var removed."} +
+ )} + + +
+ ); +} diff --git a/web/tools/cfb-builds/index.tsx b/web/tools/cfb-builds/index.tsx new file mode 100644 index 0000000..4a25f90 --- /dev/null +++ b/web/tools/cfb-builds/index.tsx @@ -0,0 +1,426 @@ +import { + CheckCircle2, + ChevronDown, + ChevronRight, + CircleDashed, + Clock, + GitBranch, + GitCommit, + Loader2, + XCircle, + Zap, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import { cn } from "@/lib/utils.ts"; +import type { + CfBuild, + CfBuildLogChunk, + CfBuildStatus, + CfbGetBuildLogsOutput, + CfbGetBuildOutput, + CfbListBuildsOutput, + CfbTriggerBuildOutput, +} from "../../../api/tools/cfb-builds.ts"; + +type AnyOutput = + | CfbListBuildsOutput + | CfbGetBuildOutput + | CfbGetBuildLogsOutput + | CfbTriggerBuildOutput; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function statusBadge(status: CfBuildStatus) { + const map: Record< + CfBuildStatus, + { label: string; className: string; Icon: typeof Loader2 } + > = { + queued: { + label: "Queued", + className: "bg-muted text-muted-foreground", + Icon: CircleDashed, + }, + initializing: { + label: "Initializing", + className: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + Icon: Loader2, + }, + running: { + label: "Running", + className: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + Icon: Loader2, + }, + success: { + label: "Success", + className: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + Icon: CheckCircle2, + }, + failure: { + label: "Failure", + className: "bg-destructive/10 text-destructive", + Icon: XCircle, + }, + cancelled: { + label: "Cancelled", + className: "bg-muted text-muted-foreground", + Icon: XCircle, + }, + }; + const m = map[status]; + const spin = status === "running" || status === "initializing"; + return ( + + + {m.label} + + ); +} + +function formatDuration(ms?: number) { + if (ms === undefined) return "—"; + if (ms < 1000) return `${ms}ms`; + const s = Math.round(ms / 100) / 10; + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s - m * 60); + return `${m}m ${rem}s`; +} + +function timeAgo(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// ─── BuildLogs ──────────────────────────────────────────────────────────────── + +function BuildLogs({ + buildId, + status, +}: { + buildId: string; + status: CfBuildStatus; +}) { + const app = useMcpApp(); + const [logs, setLogs] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const isRunning = status === "running" || status === "initializing"; + + const fetchLogs = useCallback(async () => { + if (!app) return; + try { + const res = await app.callServerTool({ + name: "cfb_get_build_logs", + arguments: { buildId }, + }); + if (res?.isError) { + const text = res.content?.find((c) => c.type === "text"); + setError(text?.type === "text" ? text.text : "Failed to fetch logs"); + return; + } + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + const parsed = JSON.parse(text.text) as CfbGetBuildLogsOutput; + setLogs(parsed.lines); + } catch { + setError("Could not parse logs response"); + } + } + } finally { + setLoading(false); + } + }, [app, buildId]); + + useEffect(() => { + fetchLogs(); + if (!isRunning) return; + const id = setInterval(fetchLogs, 2_000); + return () => clearInterval(id); + }, [fetchLogs, isRunning]); + + if (loading) { + return ( +
+ + Fetching logs… +
+ ); + } + if (error) { + return
{error}
; + } + if (!logs || logs.length === 0) { + return ( +
No log lines yet.
+ ); + } + return ( +
+			{logs.map((l) => (
+				
+ {l.line} + {l.message} +
+ ))} +
+ ); +} + +// ─── BuildRow ───────────────────────────────────────────────────────────────── + +function BuildRow({ build }: { build: CfBuild }) { + const [expanded, setExpanded] = useState(false); + const shortSha = build.commit_hash?.slice(0, 7); + const buildId = build.build_uuid; + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +// ─── views ──────────────────────────────────────────────────────────────────── + +function BuildsList({ builds }: { builds: CfBuild[] }) { + const app = useMcpApp(); + const [triggering, setTriggering] = useState(false); + + const handleTrigger = async () => { + if (!app) return; + setTriggering(true); + try { + await app.callServerTool({ + name: "cfb_trigger_build", + arguments: {}, + }); + } finally { + setTriggering(false); + } + }; + + return ( +
+
+
+

Builds

+

+ {builds.length} recent build{builds.length === 1 ? "" : "s"} +

+
+ +
+ {builds.length === 0 ? ( + + + No builds yet. + + + ) : ( + + {builds.map((b) => ( + + ))} + + )} +
+ ); +} + +function SingleBuild({ build }: { build: CfBuild }) { + return ( +
+
+

Build details

+ {statusBadge(build.status)} +
+ + + +
+ ); +} + +function StandaloneLogs({ logs }: { logs: CfBuildLogChunk[] }) { + return ( +
+

Build logs

+ + + {logs.length === 0 ? ( +

No log lines.

+ ) : ( +
+							{logs.map((l) => (
+								
+ {l.line} + {l.message} +
+ ))} +
+ )} +
+
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbBuildsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Builds + + +

+ Call{" "} + + cfb_list_builds + {" "} + to view recent Cloudflare builds. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+ {state.toolName === "cfb_list_builds" && "builds" in result ? ( + + ) : state.toolName === "cfb_get_build_logs" && "lines" in result ? ( + + ) : ( + + )} +
+ ); +} diff --git a/web/tools/cfb-secrets/index.tsx b/web/tools/cfb-secrets/index.tsx new file mode 100644 index 0000000..e60f818 --- /dev/null +++ b/web/tools/cfb-secrets/index.tsx @@ -0,0 +1,221 @@ +import { CheckCircle2, Key, Loader2, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpState } from "@/context.tsx"; +import type { + CfbDeleteSecretOutput, + CfbListSecretsOutput, + CfbSetSecretOutput, + WorkerSecretBinding, +} from "../../../api/tools/cfb-secrets.ts"; + +type AnyOutput = + | CfbListSecretsOutput + | CfbSetSecretOutput + | CfbDeleteSecretOutput; + +function isListOutput(o: AnyOutput): o is CfbListSecretsOutput { + return "secrets" in o && Array.isArray((o as CfbListSecretsOutput).secrets); +} + +function isDeleteOutput(o: AnyOutput): o is CfbDeleteSecretOutput { + return "ok" in o && "name" in o; +} + +function isSetOutput(o: AnyOutput): o is CfbSetSecretOutput { + return "name" in o && "type" in o; +} + +// ─── views ──────────────────────────────────────────────────────────────────── + +function SecretsTable({ secrets }: { secrets: WorkerSecretBinding[] }) { + if (secrets.length === 0) { + return ( +
+ + No secrets set. Use{" "} + + cfb_set_secret + {" "} + to add one. +
+ ); + } + + return ( + + + + + Name + Type + + + + {secrets.map((s) => ( + + {s.name} + + + {s.type} + + + + ))} + +
+
+ ); +} + +function SetSuccess({ binding }: { binding: CfbSetSecretOutput }) { + return ( + + + + + Secret saved + + + +
+ Name: + + {binding.name} + +
+
+ Type: + + {binding.type} + +
+

+ Cloudflare never returns secret values — the value you set is now + encrypted at rest on the Worker. +

+
+
+ ); +} + +function DeleteSuccess({ result }: { result: CfbDeleteSecretOutput }) { + return ( + + + + + Secret deleted + + + + + {result.name} + {" "} + is no longer bound to the Worker. + + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbSecretsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Secrets + + +

+ Call{" "} + + cfb_list_secrets + {" "} + to view the secrets bound to this Worker. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+
+

Worker secrets

+

+ Runtime secrets bound to the Cloudflare Worker. Values are never + returned by Cloudflare. +

+
+ {isListOutput(result) ? ( + + ) : isDeleteOutput(result) ? ( + + ) : isSetOutput(result) ? ( + + ) : null} +
+ ); +} diff --git a/web/tools/cfb-setup/index.tsx b/web/tools/cfb-setup/index.tsx new file mode 100644 index 0000000..b780cf4 --- /dev/null +++ b/web/tools/cfb-setup/index.tsx @@ -0,0 +1,322 @@ +import { + AlertTriangle, + Check, + CheckCircle2, + GitBranch, + HardDrive, + Loader2, + RefreshCw, + X, + Zap, +} from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import { cn } from "@/lib/utils.ts"; +import type { + CfbSetupInput, + CfbSetupOutput, + CfbSetupStatusOutput, + SetupError, +} from "../../../api/tools/cfb-setup.ts"; + +type AnyOutput = CfbSetupOutput | CfbSetupStatusOutput; + +function isSetupResult(o: AnyOutput): o is CfbSetupOutput { + return "ok" in o && "errors" in o; +} + +// ─── StatusRow ──────────────────────────────────────────────────────────────── + +function StatusRow({ + label, + ok, + detail, + Icon, +}: { + label: string; + ok: boolean; + detail?: string; + Icon: typeof GitBranch; +}) { + return ( +
+ + {label} + + {detail} + + {ok ? ( + + ) : ( + + )} +
+ ); +} + +// ─── ErrorList ──────────────────────────────────────────────────────────────── + +function ErrorList({ errors }: { errors: SetupError[] }) { + if (errors.length === 0) return null; + return ( +
+
+ + {errors.length} step{errors.length === 1 ? "" : "s"} failed +
+
    + {errors.map((e, idx) => ( +
  • + + + {e.step} + {" "} + · {e.message} + {e.cfErrorCode !== undefined ? ( + (CF {e.cfErrorCode}) + ) : null} + + {e.hint && {e.hint}} +
  • + ))} +
+
+ ); +} + +// ─── SetupView ──────────────────────────────────────────────────────────────── + +function SetupView({ data }: { data: AnyOutput }) { + const app = useMcpApp(); + const [running, setRunning] = useState(false); + const [latest, setLatest] = useState(data); + + const result: CfbSetupOutput | null = isSetupResult(latest) ? latest : null; + const status: CfbSetupStatusOutput | null = isSetupResult(latest) + ? null + : latest; + + const workerName = latest.workerName; + const workerTag = latest.workerTag; + const workerExists = latest.workerExists; + const repoConnected = result + ? !!result.repoConnectionId + : !!status?.repoConnected; + const prodTriggerUuid = result + ? result.prodTriggerUuid + : (status?.prodTrigger?.trigger_uuid ?? null); + const previewTriggerUuid = result + ? result.previewTriggerUuid + : (status?.previewTrigger?.trigger_uuid ?? null); + + const allWired = + workerExists && repoConnected && !!prodTriggerUuid && !!previewTriggerUuid; + const errors = result?.errors ?? []; + + const handleSetup = async () => { + if (!app) return; + setRunning(true); + try { + const res = await app.callServerTool({ + name: "cfb_setup", + arguments: {} satisfies CfbSetupInput, + }); + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + setLatest(JSON.parse(text.text) as CfbSetupOutput); + } catch { + // keep previous + } + } + } finally { + setRunning(false); + } + }; + + return ( +
+
+
+

Cloudflare Workers Builds

+

+ Worker:{" "} + + {workerName} + + {workerTag ? ( + <> + {" · tag "} + + {workerTag.slice(0, 12)} + + + ) : null} +

+
+ {allWired ? ( + + + Fully wired + + ) : ( + Needs setup + )} +
+ + + + Setup state + + + + + + + + + + + +
+ + {result && ( + + {result.ok ? "Last run succeeded" : "Last run had errors"} + + )} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbSetupPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting to host…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + + Cloudflare Workers Builds + + + +

+ Call{" "} + + cfb_setup_status + {" "} + or{" "} + + cfb_setup + {" "} + to begin. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + if (!state.toolResult) return null; + + return ( +
+ +
+ ); +} diff --git a/web/tools/cfb-versions/index.tsx b/web/tools/cfb-versions/index.tsx new file mode 100644 index 0000000..c008d44 --- /dev/null +++ b/web/tools/cfb-versions/index.tsx @@ -0,0 +1,375 @@ +import { + AlertTriangle, + CheckCircle2, + Clock, + Loader2, + RotateCcw, +} from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import type { + CfbListVersionsOutput, + CfbRollbackOutput, + VersionRow, +} from "../../../api/tools/cfb-versions.ts"; + +type AnyOutput = CfbListVersionsOutput | CfbRollbackOutput; + +function shortId(id: string) { + return id.slice(0, 8); +} + +function formatDate(iso?: string) { + if (!iso) return "—"; + return new Date(iso).toLocaleString(); +} + +// ─── RollbackDialog ─────────────────────────────────────────────────────────── + +type DialogState = "idle" | "running" | "done" | "error"; + +function RollbackDialog({ + version, + onClose, +}: { + version: VersionRow | null; + onClose: () => void; +}) { + const app = useMcpApp(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(); + const [warnings, setWarnings] = useState([]); + + if (!version) return null; + + const handleRollback = async () => { + if (!app) return; + setState("running"); + setError(undefined); + try { + const res = await app.callServerTool({ + name: "cfb_rollback", + arguments: { versionId: version.id }, + }); + if (res?.isError) { + const text = res.content?.find((c) => c.type === "text"); + throw new Error(text?.type === "text" ? text.text : "Rollback failed"); + } + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + const parsed = JSON.parse(text.text) as CfbRollbackOutput; + setWarnings(parsed.warnings ?? []); + } catch { + // no warnings parsed + } + } + setState("done"); + } catch (err) { + setError(err instanceof Error ? err.message : "Rollback failed"); + setState("error"); + } + }; + + const handleClose = () => { + setState("idle"); + setError(undefined); + setWarnings([]); + onClose(); + }; + + return ( + !open && handleClose()}> + + + + + Roll back to version + + + This creates a new deployment that sends 100% of traffic to version{" "} + + {shortId(version.id)} + + . Cloudflare may reject the rollback if the version's bindings have + incompatible data shapes (D1, R2, KV, DO). + + + +
+ +
+ Rolling back does NOT revert build vars — vars are part of the new + deployment. +
+
+ + {state === "done" && ( +
+ + Rollback applied. +
+ )} + {warnings.length > 0 && ( +
    + {warnings.map((w) => ( +
  • {w}
  • + ))} +
+ )} + {state === "error" && ( +

{error}

+ )} + + + + {state !== "done" && ( + + )} + +
+
+ ); +} + +// ─── VersionsTable ──────────────────────────────────────────────────────────── + +function VersionsTable({ versions }: { versions: VersionRow[] }) { + const [target, setTarget] = useState(null); + + return ( + <> + + + + + Version + # + Created + Status + + + + + {versions.map((v) => ( + + + {shortId(v.id)} + + + {v.number ?? "—"} + + + + + {formatDate(v.created_on)} + + + + {v.isActive ? ( + + + Active + + ) : ( + + Available + + )} + + + + + + ))} + +
+
+ setTarget(null)} /> + + ); +} + +// ─── RollbackResultView ─────────────────────────────────────────────────────── + +function RollbackResultView({ result }: { result: CfbRollbackOutput }) { + return ( + + + + + Rollback applied + + + +
+ Deployment id:{" "} + + {result.deployment.id} + +
+ {result.warnings.length > 0 && ( +
+
+ + {result.warnings.length} warning + {result.warnings.length === 1 ? "" : "s"} +
+
    + {result.warnings.map((w) => ( +
  • {w}
  • + ))} +
+
+ )} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbVersionsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Versions + + +

+ Call{" "} + + cfb_list_versions + {" "} + to view the last 100 Worker versions. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+ {state.toolName === "cfb_list_versions" && "versions" in result ? ( + <> +
+

Worker versions

+

+ Last 100 versions. The active version receives 100% of traffic + unless a gradual deployment is in progress. +

+
+ + + ) : ( + + )} +
+ ); +}