diff --git a/.changeset/cloudflare-deploy-adapter.md b/.changeset/cloudflare-deploy-adapter.md new file mode 100644 index 0000000..1087c31 --- /dev/null +++ b/.changeset/cloudflare-deploy-adapter.md @@ -0,0 +1,9 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Add `cloudflareTarget` — the first real `DeployTarget` adapter for bootstrap mode. + +`cloudflareTarget({ session, ... })` ships the built app to Cloudflare via the `wrangler` CLI, run inside the build's runner session: install, build, then deploy to **Workers** (SSR) or **Pages** (SSG/SPA), reporting the live URL wrangler printed. Credentials come from `apiToken`/`accountId` (or `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID`) and are passed to `wrangler` through the command environment, so they work whether the session is local or a container. It never throws — a missing token, failed build, or failed deploy return `{ deployed: false, detail }` so the final phase narrates rather than crashing. + +Wire it on the existing seam: `agentDeploy(deployer, { target: cloudflareTarget({ session, projectName }) })`. diff --git a/packages/ai-autopilot/src/bootstrap/cloudflare.test.ts b/packages/ai-autopilot/src/bootstrap/cloudflare.test.ts new file mode 100644 index 0000000..0fabb09 --- /dev/null +++ b/packages/ai-autopilot/src/bootstrap/cloudflare.test.ts @@ -0,0 +1,133 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { cloudflareTarget, type DeployExecutor } from './cloudflare.js' +import type { ExecOptions, ExecResult } from '../runner/types.js' +import type { DeployPlan, DeployTargetContext } from './types.js' + +interface Call { + command: string + opts?: ExecOptions +} + +/** A fake executor: records commands and returns canned results (by matching a substring). */ +function fakeExec(responses: Array<{ match: string; result: Partial }>): DeployExecutor & { calls: Call[] } { + const calls: Call[] = [] + return { + calls, + async exec(command: string, opts?: ExecOptions): Promise { + calls.push({ command, ...(opts ? { opts } : {}) }) + const hit = responses.find((r) => command.includes(r.match)) + return { stdout: '', stderr: '', exitCode: 0, ...(hit?.result ?? {}) } + }, + } +} + +function ctxFor(render: DeployPlan['render']): DeployTargetContext { + return { plan: { render, target: 'cloudflare', reason: 'test' }, intent: 'an app' } +} + +const OK: Array<{ match: string; result: Partial }> = [ + { match: 'wrangler deploy', result: { stdout: 'Published orders-app\nhttps://orders-app.acme.workers.dev' } }, + { match: 'wrangler pages deploy', result: { stdout: 'Success!\nTake a peek: https://abc123.orders-app.pages.dev' } }, +] + +test('SSR ships to Workers and returns the workers.dev URL', async () => { + const exec = fakeExec(OK) + const target = cloudflareTarget({ session: exec, apiToken: 'tok', accountId: 'acct' }) + const res = await target.deploy(ctxFor('ssr')) + assert.equal(res.deployed, true) + assert.equal(res.url, 'https://orders-app.acme.workers.dev') + const commands = exec.calls.map((c) => c.command) + assert.deepEqual(commands, ['npm install', 'npm run build', 'npx wrangler deploy']) + // credentials are passed to wrangler via the command env, not to install/build + assert.deepEqual(exec.calls[2]!.opts?.env, { CLOUDFLARE_API_TOKEN: 'tok', CLOUDFLARE_ACCOUNT_ID: 'acct' }) + assert.equal(exec.calls[0]!.opts?.env, undefined) +}) + +test('SSG ships to Pages with the project name and returns the pages.dev URL', async () => { + const exec = fakeExec(OK) + const target = cloudflareTarget({ session: exec, apiToken: 'tok', projectName: 'orders-app' }) + const res = await target.deploy(ctxFor('ssg')) + assert.equal(res.deployed, true) + assert.equal(res.url, 'https://abc123.orders-app.pages.dev') + assert.equal( + exec.calls.at(-1)!.command, + 'npx wrangler pages deploy dist/client --project-name orders-app', + ) +}) + +test('a Pages deploy without a project name does not run wrangler', async () => { + const exec = fakeExec(OK) + const target = cloudflareTarget({ session: exec, apiToken: 'tok' }) + const res = await target.deploy(ctxFor('spa')) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /project name/) + assert.equal(exec.calls.some((c) => c.command.includes('wrangler')), false) +}) + +test('a missing API token short-circuits before any command runs', async () => { + const saved = process.env.CLOUDFLARE_API_TOKEN + delete process.env.CLOUDFLARE_API_TOKEN + try { + const exec = fakeExec(OK) + const res = await cloudflareTarget({ session: exec }).deploy(ctxFor('ssr')) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /CLOUDFLARE_API_TOKEN/) + assert.equal(exec.calls.length, 0) + } finally { + if (saved !== undefined) process.env.CLOUDFLARE_API_TOKEN = saved + } +}) + +test('the API token falls back to the environment', async () => { + const saved = process.env.CLOUDFLARE_API_TOKEN + process.env.CLOUDFLARE_API_TOKEN = 'env-tok' + try { + const exec = fakeExec(OK) + const res = await cloudflareTarget({ session: exec }).deploy(ctxFor('ssr')) + assert.equal(res.deployed, true) + assert.equal(exec.calls.at(-1)!.opts?.env?.CLOUDFLARE_API_TOKEN, 'env-tok') + } finally { + if (saved === undefined) delete process.env.CLOUDFLARE_API_TOKEN + else process.env.CLOUDFLARE_API_TOKEN = saved + } +}) + +test('a build failure reports the blocker and skips wrangler', async () => { + const exec = fakeExec([{ match: 'npm run build', result: { exitCode: 1, stderr: 'type error in +Page.tsx' } }]) + const res = await cloudflareTarget({ session: exec, apiToken: 'tok' }).deploy(ctxFor('ssr')) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /build failed/) + assert.match(res.detail ?? '', /type error/) + assert.equal(exec.calls.some((c) => c.command.includes('wrangler')), false) +}) + +test('a wrangler failure surfaces its stderr', async () => { + const exec = fakeExec([{ match: 'wrangler deploy', result: { exitCode: 1, stderr: 'Authentication error [code: 10000]' } }]) + const res = await cloudflareTarget({ session: exec, apiToken: 'tok' }).deploy(ctxFor('ssr')) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /wrangler failed/) + assert.match(res.detail ?? '', /Authentication error/) +}) + +test('install and build can be skipped when already built', async () => { + const exec = fakeExec(OK) + const target = cloudflareTarget({ session: exec, apiToken: 'tok', installCommand: false, buildCommand: false }) + await target.deploy(ctxFor('ssr')) + assert.deepEqual(exec.calls.map((c) => c.command), ['npx wrangler deploy']) +}) + +test('product override forces Pages for an SSR plan', async () => { + const exec = fakeExec(OK) + const target = cloudflareTarget({ session: exec, apiToken: 'tok', projectName: 'app', product: 'pages' }) + await target.deploy(ctxFor('ssr')) + assert.match(exec.calls.at(-1)!.command, /wrangler pages deploy/) +}) + +test('a clean deploy with no URL in output still reports deployed', async () => { + const exec = fakeExec([{ match: 'wrangler deploy', result: { stdout: 'done, no url here' } }]) + const res = await cloudflareTarget({ session: exec, apiToken: 'tok', installCommand: false, buildCommand: false }).deploy(ctxFor('ssr')) + assert.equal(res.deployed, true) + assert.equal(res.url, undefined) + assert.match(res.detail ?? '', /no URL/) +}) diff --git a/packages/ai-autopilot/src/bootstrap/cloudflare.ts b/packages/ai-autopilot/src/bootstrap/cloudflare.ts new file mode 100644 index 0000000..41b3ce0 --- /dev/null +++ b/packages/ai-autopilot/src/bootstrap/cloudflare.ts @@ -0,0 +1,155 @@ +import type { ExecOptions, ExecResult } from '../runner/types.js' +import type { DeployResult, DeployTarget, DeployTargetContext, RenderMode } from './types.js' + +/** + * The slice of a {@link RunnerSession} the Cloudflare adapter needs: a shell to + * run install / build / `wrangler` in the workspace the app was built in. A full + * `RunnerSession` satisfies this structurally, so the caller passes the same + * session it handed to the build step. + */ +export interface DeployExecutor { + exec(command: string, opts?: ExecOptions): Promise +} + +/** Which Cloudflare product to ship to. `auto` maps the render mode: SSR → Workers, SSG/SPA → Pages. */ +export type CloudflareProduct = 'auto' | 'workers' | 'pages' + +/** Options for {@link cloudflareTarget}. */ +export interface CloudflareTargetOptions { + /** The workspace to build + deploy in — the session the build wrote to. */ + session: DeployExecutor + /** Cloudflare API token. Falls back to `CLOUDFLARE_API_TOKEN` in the environment. */ + apiToken?: string + /** Cloudflare account id. Falls back to `CLOUDFLARE_ACCOUNT_ID` in the environment. */ + accountId?: string + /** Pages project name (required for a Pages deploy). */ + projectName?: string + /** Force a product instead of deriving it from the render mode. Default `'auto'`. */ + product?: CloudflareProduct + /** Built static output directory for a Pages deploy. Default `dist/client` (Vike). */ + outputDir?: string + /** Install command. Default `npm install`. Set `false` to skip (already installed). */ + installCommand?: string | false + /** Build command. Default `npm run build`. Set `false` to skip (already built). */ + buildCommand?: string | false + /** Extra arguments appended to the `wrangler` command. */ + wranglerArgs?: readonly string[] + /** Per-command timeout in ms, passed to `exec`. */ + timeoutMs?: number + /** Target name, matched against {@link DeployPlan.target}. Default `'cloudflare'`. */ + name?: string +} + +const DEFAULT_INSTALL = 'npm install' +const DEFAULT_BUILD = 'npm run build' +const DEFAULT_OUTPUT_DIR = 'dist/client' +// wrangler prints the live deployment URL; grab the last workers.dev / pages.dev URL it emits. +const URL_RE = /https?:\/\/[^\s'"]+\.(?:workers|pages)\.dev[^\s'"]*/g + +/** Last ~500 chars of a command's stderr (or stdout), for a compact failure detail. */ +function tail(result: ExecResult): string { + const text = (result.stderr || result.stdout || '').trim() + return text.length > 500 ? `…${text.slice(-500)}` : text +} + +/** SSR needs a server (Workers); prebuilt SSG and client-only SPA go to Pages. */ +function productFor(render: RenderMode, override: CloudflareProduct): 'workers' | 'pages' { + if (override !== 'auto') return override + return render === 'ssr' ? 'workers' : 'pages' +} + +/** + * A real {@link DeployTarget} that ships the built app to Cloudflare via the + * `wrangler` CLI, run inside the build's runner session. It installs, builds, and + * deploys — to **Workers** for SSR or **Pages** for SSG/SPA — then reports the + * live URL wrangler printed. + * + * It never throws: a missing token, a failed build, or a failed deploy come back + * as `{ deployed: false, detail }` so the final phase can narrate the outcome + * rather than crashing the app that was already built. + * + * Credentials come from `apiToken` / `accountId` (or `CLOUDFLARE_API_TOKEN` / + * `CLOUDFLARE_ACCOUNT_ID`) and are passed to `wrangler` through the command + * environment, so they work the same whether the session is local or a container. + * + * ```ts + * deploy: agentDeploy(deployer, { + * target: cloudflareTarget({ session, projectName: 'orders-app' }), + * }) + * ``` + */ +export function cloudflareTarget(options: CloudflareTargetOptions): DeployTarget { + const { + session, + projectName, + product = 'auto', + outputDir = DEFAULT_OUTPUT_DIR, + wranglerArgs = [], + timeoutMs, + name = 'cloudflare', + } = options + const installCommand = options.installCommand === undefined ? DEFAULT_INSTALL : options.installCommand + const buildCommand = options.buildCommand === undefined ? DEFAULT_BUILD : options.buildCommand + + return { + name, + async deploy(ctx: DeployTargetContext): Promise { + const apiToken = options.apiToken ?? process.env.CLOUDFLARE_API_TOKEN + const accountId = options.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID + if (!apiToken) { + return { + deployed: false, + detail: 'no Cloudflare API token — set CLOUDFLARE_API_TOKEN or pass apiToken.', + } + } + + const target = productFor(ctx.plan.render, product) + if (target === 'pages' && !projectName) { + return { + deployed: false, + detail: 'a Pages deploy needs a project name — pass projectName.', + } + } + + const base: ExecOptions = timeoutMs != null ? { timeoutMs } : {} + + // Install + build in the workspace (no credentials needed for these). + if (installCommand) { + const installed = await session.exec(installCommand, base) + if (installed.exitCode !== 0) { + return { deployed: false, detail: `install failed (\`${installCommand}\`): ${tail(installed)}` } + } + } + if (buildCommand) { + const built = await session.exec(buildCommand, base) + if (built.exitCode !== 0) { + return { deployed: false, detail: `build failed (\`${buildCommand}\`): ${tail(built)}` } + } + } + + // Deploy with credentials passed through the command environment. + const env: Record = { CLOUDFLARE_API_TOKEN: apiToken } + if (accountId) env.CLOUDFLARE_ACCOUNT_ID = accountId + const extra = wranglerArgs.length ? ` ${wranglerArgs.join(' ')}` : '' + const command = + target === 'workers' + ? `npx wrangler deploy${extra}` + : `npx wrangler pages deploy ${outputDir} --project-name ${projectName}${extra}` + + const shipped = await session.exec(command, { ...base, env }) + if (shipped.exitCode !== 0) { + return { deployed: false, detail: `wrangler failed (\`${command}\`): ${tail(shipped)}` } + } + + const urls = `${shipped.stdout}\n${shipped.stderr}`.match(URL_RE) + const url = urls?.[urls.length - 1] + return { + deployed: true, + ...(url ? { url } : {}), + detail: url + ? `Deployed to Cloudflare ${target} at ${url}` + : `Deployed to Cloudflare ${target} (no URL found in wrangler output).`, + } + }, + } +} diff --git a/packages/ai-autopilot/src/bootstrap/index.ts b/packages/ai-autopilot/src/bootstrap/index.ts index bebab34..79cc537 100644 --- a/packages/ai-autopilot/src/bootstrap/index.ts +++ b/packages/ai-autopilot/src/bootstrap/index.ts @@ -33,6 +33,12 @@ export { type FakeDeployTargetOptions, } from './deploy.js' export { serveCheck, mergeChecklists, type ServeCheckOptions } from './serve-check.js' +export { + cloudflareTarget, + type CloudflareTargetOptions, + type CloudflareProduct, + type DeployExecutor, +} from './cloudflare.js' export type { BootstrapScope, BootstrapPhase, diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index eda5e9b..5bd9a85 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -73,8 +73,8 @@ * - {@link agentArchitect} / {@link supervisorBuild} — the default step wirings * - {@link loopChecklist} / {@link loopImprove} — the full-fledged loop steps * - {@link agentDeploy} + the {@link DeployTarget} seam ({@link planOnlyTarget}, - * {@link FakeDeployTarget}) — the final phase: decide SSR/SSG/SPA + target and - * narrate; real adapters are infra-gated + * {@link FakeDeployTarget}, {@link cloudflareTarget}) — the final phase: decide + * SSR/SSG/SPA + target and narrate, then ship via a real adapter * * Scale mode keeps a compact `CODE-OVERVIEW.md` the agent reads first in a large * repo, refreshed only on *material* change (build tooling, test framework, a @@ -228,7 +228,11 @@ export { DEFAULT_DEPLOY_TARGETS, serveCheck, mergeChecklists, + cloudflareTarget, type ServeCheckOptions, + type CloudflareTargetOptions, + type CloudflareProduct, + type DeployExecutor, type ArchitectAgentOptions, type SupervisorBuildOptions, type LoopStepOptions,