diff --git a/.changeset/framework-real-deploy.md b/.changeset/framework-real-deploy.md new file mode 100644 index 0000000..c45e82d --- /dev/null +++ b/.changeset/framework-real-deploy.md @@ -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. diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 809f44e..a4444f8 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -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[] = [] @@ -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) diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 0dfeaa5..d803cce 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -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' @@ -34,7 +36,10 @@ Options: --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: acceptEdits). --dangerously-skip-permissions Bypass all agent permission checks (sandboxes only). - --deploy Narrate a deploy decision to this target (e.g. cloudflare, dokploy). + --deploy Deploy to this target (cloudflare, dokploy) or narrate any other. + --cf-project Cloudflare Pages project name (for a Pages deploy). + --dokploy-url Dokploy instance URL (required for --deploy dokploy). + --dokploy-app Dokploy application id (required for --deploy dokploy). --port Dashboard port (default: 4477). --no-dashboard Do not start the localhost dashboard. --session-link Link to the live agent session (shown on the dashboard). @@ -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 @@ -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 @@ -188,6 +205,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise, + 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 { return new Promise(resolvePromise => { diff --git a/packages/framework/src/host-exec.test.ts b/packages/framework/src/host-exec.test.ts new file mode 100644 index 0000000..ced4a23 --- /dev/null +++ b/packages/framework/src/host-exec.test.ts @@ -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/) +}) diff --git a/packages/framework/src/host-exec.ts b/packages/framework/src/host-exec.ts new file mode 100644 index 0000000..a10a9d6 --- /dev/null +++ b/packages/framework/src/host-exec.ts @@ -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 { + 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(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 }) + }, + ) + }) + }, + } +} diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 3e86d1b..db24d45 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -39,6 +39,7 @@ export { driverChecklist, driverImprove, decideDeploy, + deployWith, parseArchitectPlan, architectPrompt, buildPrompt, @@ -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, diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 2e94958..90fc049 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -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). */ @@ -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. */ @@ -115,7 +122,11 @@ export async function runFramework(opts: RunFrameworkOptions): Promise { 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'] }) diff --git a/packages/framework/src/steps.ts b/packages/framework/src/steps.ts index 6fe0386..9cd22cd 100644 --- a/packages/framework/src/steps.ts +++ b/packages/framework/src/steps.ts @@ -6,6 +6,7 @@ import type { BuildContext, DeployContext, DeployOutcome, + DeployTarget, LoopPassContext, PlannedSubtask, SubtaskResult, @@ -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 { + 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.