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
12 changes: 12 additions & 0 deletions .changeset/framework-real-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@gemstack/framework": minor
---

feat: `framework --deploy` actually ships via real deploy targets

Wire ai-autopilot's `cloudflareTarget` / `dokployTarget` into the CLI so
`--deploy cloudflare` / `--deploy dokploy` execute the deploy instead of only
narrating a plan. Adds `--cf-project`, `--dokploy-url`, `--dokploy-app`, a
`hostExecutor` that runs `wrangler` in the agent's workspace, and the `deployWith`
step. Creds come from the environment; targets never throw on missing config
(they report `{ deployed: false }`). `--fake` stays plan-only and deterministic.
20 changes: 19 additions & 1 deletion packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { parseArgs, runCli, type CliIO } from './cli.js'
import { buildDeployTarget, parseArgs, runCli, type CliIO } from './cli.js'

function capture(): { io: CliIO; out: string[]; err: string[] } {
const out: string[] = []
Expand Down Expand Up @@ -41,6 +41,24 @@ test('runCli usage error exits 2', async () => {
assert.equal(await runCli([], io), 2) // no intent, not fake
})

test('buildDeployTarget builds cloudflare, requires dokploy config, ignores unknown', () => {
assert.equal(buildDeployTarget('cloudflare', {}, '/ws').target?.name, 'cloudflare')
assert.match(buildDeployTarget('dokploy', {}, '/ws').error!, /dokploy-url and --dokploy-app/)
assert.equal(
buildDeployTarget('dokploy', { dokployUrl: 'https://d.example', dokployApp: 'app-1' }, '/ws').target?.name,
'dokploy',
)
const unknown = buildDeployTarget('fly', {}, '/ws')
assert.equal(unknown.target, undefined)
assert.equal(unknown.error, undefined)
})

test('runCli errors when --deploy dokploy lacks its config', async () => {
const { io } = capture()
const code = await runCli(['--deploy', 'dokploy', '--no-dashboard', 'a small app'], io)
assert.equal(code, 2)
})

test('runCli --fake --no-dashboard runs the whole flow offline to production-grade', async () => {
const { io, out } = capture()
const code = await runCli(['--fake', '--no-dashboard'], io)
Expand Down
62 changes: 61 additions & 1 deletion packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { cloudflareTarget, dokployTarget, type DeployTarget } from '@gemstack/ai-autopilot'
import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type PermissionMode } from './driver/index.js'
import { hostExecutor } from './host-exec.js'
import { startDashboard, type Dashboard } from './dashboard/index.js'
import { formatFrameworkEvent, type FrameworkEvent } from './events.js'
import { runFramework, type DeployDecision, type RunFrameworkOptions } from './run.js'
Expand Down Expand Up @@ -34,7 +36,10 @@ Options:
--permission-mode <mode> Claude Code permission mode: default | acceptEdits |
bypassPermissions | plan (default: acceptEdits).
--dangerously-skip-permissions Bypass all agent permission checks (sandboxes only).
--deploy <target> Narrate a deploy decision to this target (e.g. cloudflare, dokploy).
--deploy <target> Deploy to this target (cloudflare, dokploy) or narrate any other.
--cf-project <name> Cloudflare Pages project name (for a Pages deploy).
--dokploy-url <url> Dokploy instance URL (required for --deploy dokploy).
--dokploy-app <id> Dokploy application id (required for --deploy dokploy).
--port <n> Dashboard port (default: 4477).
--no-dashboard Do not start the localhost dashboard.
--session-link <url> Link to the live agent session (shown on the dashboard).
Expand All @@ -57,6 +62,9 @@ export interface CliOptions {
scope: 'prototype' | 'full'
maxPasses?: number
deploy?: string | undefined
cfProject?: string | undefined
dokployUrl?: string | undefined
dokployApp?: string | undefined
port?: number
dashboard: boolean
sessionLink?: string | undefined
Expand Down Expand Up @@ -116,6 +124,15 @@ export function parseArgs(argv: string[]): CliOptions {
case '--deploy':
opts.deploy = argv[++i]
break
case '--cf-project':
opts.cfProject = argv[++i]
break
case '--dokploy-url':
opts.dokployUrl = argv[++i]
break
case '--dokploy-app':
opts.dokployApp = argv[++i]
break
case '--session-link':
opts.sessionLink = argv[++i]
break
Expand Down Expand Up @@ -188,6 +205,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
? FAKE_DEPLOY
: undefined

// A real deploy target actually ships the app. Only for live runs against a
// known target; --fake stays plan-only and deterministic. An unknown target
// just narrates the decision. Real targets never throw on missing creds.
let deployTarget: DeployTarget | undefined
if (!fake && opts.deploy) {
const built = buildDeployTarget(opts.deploy, opts, cwd)
if (built.error) {
io.err(built.error)
io.err('Run `framework --help` for usage.')
return 2
}
deployTarget = built.target
}

let dashboard: Dashboard | undefined
if (opts.dashboard) {
try {
Expand All @@ -212,6 +243,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(opts.model ? { model: opts.model } : {}),
...(opts.maxPasses ? { maxPasses: opts.maxPasses } : {}),
...(deploy ? { deploy } : {}),
...(deployTarget ? { deployTarget } : {}),
...(fake ? { signals: FAKE_SIGNALS } : {}),
...(opts.sessionLink ? { sessionLink: opts.sessionLink } : {}),
}
Expand All @@ -236,6 +268,34 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
}

/**
* Build a real {@link DeployTarget} for a known target name, or return an error /
* nothing. `cloudflare` runs `wrangler` via a host executor bound to the build's
* workspace; `dokploy` is a fetch to a self-hosted instance. Creds come from the
* environment (CLOUDFLARE_API_TOKEN / DOKPLOY_AUTH_TOKEN).
*/
export function buildDeployTarget(
name: string,
opts: Pick<CliOptions, 'cfProject' | 'dokployUrl' | 'dokployApp'>,
cwd: string,
): { target?: DeployTarget; error?: string } {
if (name === 'cloudflare') {
return {
target: cloudflareTarget({
session: hostExecutor(cwd),
...(opts.cfProject ? { projectName: opts.cfProject } : {}),
}),
}
}
if (name === 'dokploy') {
if (!opts.dokployUrl || !opts.dokployApp) {
return { error: '--deploy dokploy requires --dokploy-url and --dokploy-app' }
}
return { target: dokployTarget({ serverUrl: opts.dokployUrl, applicationId: opts.dokployApp }) }
}
return {} // Unknown target: narrate the decision only.
}

/** Resolve when the process is interrupted (Ctrl+C), so the dashboard stays up. */
function waitForInterrupt(): Promise<void> {
return new Promise(resolvePromise => {
Expand Down
25 changes: 25 additions & 0 deletions packages/framework/src/host-exec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { tmpdir } from 'node:os'
import { hostExecutor } from './host-exec.js'

test('hostExecutor runs a command in the given cwd and captures stdout', async () => {
const exec = hostExecutor(tmpdir())
const result = await exec.exec(`node -e "process.stdout.write('hi from host')"`)
assert.equal(result.exitCode, 0)
assert.match(result.stdout, /hi from host/)
})

test('hostExecutor reports a non-zero exit code without throwing', async () => {
const exec = hostExecutor(tmpdir())
const result = await exec.exec(`node -e "process.exit(3)"`)
assert.equal(result.exitCode, 3)
})

test('hostExecutor merges per-command env', async () => {
const exec = hostExecutor(tmpdir(), { env: { ...process.env, BASE_VAR: 'base' } })
const result = await exec.exec(`node -e "process.stdout.write(process.env.BASE_VAR + ':' + process.env.EXTRA)"`, {
env: { EXTRA: 'extra' },
})
assert.match(result.stdout, /base:extra/)
})
43 changes: 43 additions & 0 deletions packages/framework/src/host-exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { exec as cpExec } from 'node:child_process'
import { isAbsolute, join } from 'node:path'
import type { DeployExecutor, ExecOptions, ExecResult } from '@gemstack/ai-autopilot'

/** Options for {@link hostExecutor}. */
export interface HostExecutorOptions {
/** Base environment for every command. Default `process.env`. */
env?: NodeJS.ProcessEnv
/** Max stdout/stderr bytes to buffer per command. Default 16 MiB. */
maxBuffer?: number
}

/**
* A {@link DeployExecutor} that runs shell commands on the host, in the
* workspace the wrapped agent built (its `cwd`). This is what lets a real deploy
* target (e.g. `cloudflareTarget`) install / build / run `wrangler` against the
* code Claude Code just wrote, since the runner seam's `LocalRunner` cannot: it
* mkdtemps a fresh workspace and deletes it on dispose.
*
* Never rejects: a non-zero exit or a spawn error resolves to an {@link ExecResult}
* with the exit code and captured output, matching the runner-session contract
* the deploy targets expect.
*/
export function hostExecutor(cwd: string, opts: HostExecutorOptions = {}): DeployExecutor {
const baseEnv = opts.env ?? process.env
const maxBuffer = opts.maxBuffer ?? 16 * 1024 * 1024
return {
exec(command: string, execOpts: ExecOptions = {}): Promise<ExecResult> {
const dir = execOpts.cwd ? (isAbsolute(execOpts.cwd) ? execOpts.cwd : join(cwd, execOpts.cwd)) : cwd
const env = execOpts.env ? { ...baseEnv, ...execOpts.env } : baseEnv
return new Promise<ExecResult>(resolvePromise => {
cpExec(
command,
{ cwd: dir, env, timeout: execOpts.timeoutMs ?? 0, maxBuffer },
(err, stdout, stderr) => {
const exitCode = err ? (typeof err.code === 'number' ? err.code : 1) : 0
resolvePromise({ stdout: String(stdout), stderr: String(stderr), exitCode })
},
)
})
},
}
}
4 changes: 3 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
driverChecklist,
driverImprove,
decideDeploy,
deployWith,
parseArchitectPlan,
architectPrompt,
buildPrompt,
Expand All @@ -47,9 +48,10 @@ export {
type DriverStepOptions,
} from './steps.js'
export { runFramework, type RunFrameworkOptions, type RunFrameworkResult, type DeployDecision } from './run.js'
export { hostExecutor, type HostExecutorOptions } from './host-exec.js'
export { type FrameworkEvent, formatFrameworkEvent } from './events.js'
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
export { runCli, parseArgs, type CliIO, type CliOptions } from './cli.js'
export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js'
export {
fakeDriver,
FAKE_INTENT,
Expand Down
15 changes: 13 additions & 2 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
type BootstrapEvent,
type BootstrapResult,
type BootstrapScope,
type DeployTarget,
type FrameworkDetection,
type FrameworkSignals,
} from '@gemstack/ai-autopilot'
import type { Driver, DriverSession } from './driver/index.js'
import { decideDeploy, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js'
import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js'
import type { FrameworkEvent } from './events.js'

/** The deploy decision to narrate at the end (plan-only in v1: it does not ship). */
Expand Down Expand Up @@ -39,6 +40,12 @@ export interface RunFrameworkOptions {
maxPasses?: number
/** A deploy decision to narrate at the end. Omit to skip the deploy phase. */
deploy?: DeployDecision
/**
* A real {@link DeployTarget} to *execute* the decided plan (e.g.
* `cloudflareTarget` / `dokployTarget`). Requires {@link deploy}. Omit to only
* narrate a plan-only decision.
*/
deployTarget?: DeployTarget
/** A claude.ai/code (or other) link to the live agent session, for the dashboard. */
sessionLink?: string
/** Interrupt the run between phases. */
Expand Down Expand Up @@ -115,7 +122,11 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
build: driverBuild(session),
checklist: driverChecklist(session),
improve: driverImprove(session),
...(opts.deploy ? { deploy: decideDeploy(opts.deploy) } : {}),
...(opts.deploy && opts.deployTarget
? { deploy: deployWith(opts.deploy, opts.deployTarget) }
: opts.deploy
? { deploy: decideDeploy(opts.deploy) }
: {}),
},
})
const result = await bootstrap.run()
Expand Down
25 changes: 23 additions & 2 deletions packages/framework/src/steps.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { DecisionLedger, type SupervisorEvent } from '@gemstack/ai-autopilot'
import { DecisionLedger, type DeployTarget, type SupervisorEvent } from '@gemstack/ai-autopilot'
import { FakeDriver } from './driver/index.js'
import { driverArchitect, driverBuild, driverChecklist, driverImprove, parseArchitectPlan } from './steps.js'
import { deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove, parseArchitectPlan } from './steps.js'

const PLAN = { stack: 'Vike + universal-orm', narration: 'orders app', decisions: [] }

Expand Down Expand Up @@ -57,6 +57,27 @@ test('driverChecklist treats a verdict-less reply as passing', async () => {
assert.deepEqual(verdict.blockers, [])
})

test('deployWith runs the target against the decided plan and uses its name', async () => {
const calls: string[] = []
const target: DeployTarget = {
name: 'cloudflare',
deploy: ctx => {
calls.push(ctx.plan.render)
return { deployed: true, url: 'https://app.workers.dev', detail: 'shipped' }
},
}
const outcome = await deployWith({ render: 'ssr', reason: 'per-request data' }, target)({
plan: PLAN,
scope: 'full',
intent: 'orders app',
productionGrade: true,
})
assert.equal(outcome.plan.target, 'cloudflare')
assert.equal(outcome.result.deployed, true)
assert.equal(outcome.result.url, 'https://app.workers.dev')
assert.deepEqual(calls, ['ssr'])
})

test('driverImprove prompts the driver with the blockers', async () => {
const session = await new FakeDriver({ turns: [{ text: 'fixed' }] }).start({ cwd: '/ws' })
await driverImprove(session)({ pass: 1, plan: PLAN, intent: 'x', blockers: ['add auth'] })
Expand Down
18 changes: 18 additions & 0 deletions packages/framework/src/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
BuildContext,
DeployContext,
DeployOutcome,
DeployTarget,
LoopPassContext,
PlannedSubtask,
SubtaskResult,
Expand Down Expand Up @@ -167,6 +168,23 @@ export function decideDeploy(
return () => ({ plan, result: { deployed: false, detail: 'plan-only (no deploy target wired)' } })
}

/**
* Deploy for real: run a {@link DeployTarget} against the decided plan. The plan
* is already chosen (the CLI decided render + target); this executes it. The
* target's own name wins for {@link DeployPlan.target}. Real targets never
* throw, so a missing token / build failure comes back as `{ deployed: false }`.
*/
export function deployWith(
decision: { render: 'ssr' | 'ssg' | 'spa'; reason: string },
target: DeployTarget,
): (ctx: DeployContext) => Promise<DeployOutcome> {
return async ctx => {
const plan = { render: decision.render, target: target.name, reason: decision.reason }
const result = await target.deploy({ plan, intent: ctx.intent, ...(ctx.signal ? { signal: ctx.signal } : {}) })
return { plan, result }
}
}

/**
* Parse the architect's JSON out of a driver turn, with safe fallbacks so a
* loose reply never crashes the flow. Exported for testing.
Expand Down
Loading