Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/cloudflare-deploy-adapter.md
Original file line number Diff line number Diff line change
@@ -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 }) })`.
133 changes: 133 additions & 0 deletions packages/ai-autopilot/src/bootstrap/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -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<ExecResult> }>): DeployExecutor & { calls: Call[] } {
const calls: Call[] = []
return {
calls,
async exec(command: string, opts?: ExecOptions): Promise<ExecResult> {
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<ExecResult> }> = [
{ 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/)
})
155 changes: 155 additions & 0 deletions packages/ai-autopilot/src/bootstrap/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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<ExecResult>
}

/** 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<DeployResult> {
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<string, string> = { 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).`,
}
},
}
}
6 changes: 6 additions & 0 deletions packages/ai-autopilot/src/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading