diff --git a/.changeset/framework-app-preview.md b/.changeset/framework-app-preview.md new file mode 100644 index 0000000..e50e55d --- /dev/null +++ b/.changeset/framework-app-preview.md @@ -0,0 +1,5 @@ +--- +'@gemstack/framework': minor +--- + +Keep the generated app running after a successful `--serve` run and surface a live preview link. Once the boot-and-serve gate passes, the app is booted once more and left serving; the dashboard shows an "open your app" banner and the terminal prints the URL, both live until you stop the run (Ctrl+C tears the app down). `runFramework` now returns an optional `preview` handle (`{ url, command, stop() }`) so callers own the app's lifecycle. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 7ffbd49..42e88b3 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -38,7 +38,8 @@ Options: --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: acceptEdits). --dangerously-skip-permissions Bypass all agent permission checks (sandboxes only). - --serve Gate the loop on the app actually running (e.g. "npm run dev"). + --serve Gate the loop on the app actually running (e.g. "npm run dev"), + then keep it serving with a preview link on the dashboard. --serve-install Install command before serving (e.g. "npm install"). --serve-build Build command before serving (e.g. "npm run build"). --serve-port Port the app listens on (default: 3000). @@ -283,6 +284,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise +

Stack & rationale

@@ -166,6 +176,12 @@ function onEvent(fe) { $('session').textContent = s; if (fe.sessionLink) setSessionLink(undefined, fe.sessionLink); } else if (fe.kind === 'session-update') setSessionLink(fe.sessionId, fe.sessionLink); + else if (fe.kind === 'preview') { + const a = $('app-link'); + a.href = fe.url; a.textContent = fe.url; + $('app-banner').hidden = false; + log('\\u25b6 your app is running at ' + fe.url); + } else if (fe.kind === 'bootstrap') bootstrap(fe.event); else if (fe.kind === 'driver') driver(fe.event); else if (fe.kind === 'log') log(fe.message); diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts index de83763..e807464 100644 --- a/packages/framework/src/events.test.ts +++ b/packages/framework/src/events.test.ts @@ -27,3 +27,10 @@ test('formatFrameworkEvent renders a session-update line', () => { ' session abc123 — https://x.dev/s/abc123', ) }) + +test('formatFrameworkEvent renders a preview line', () => { + assert.equal( + formatFrameworkEvent({ kind: 'preview', url: 'http://localhost:3000', command: 'npm run dev' }), + '▶ your app is running at http://localhost:3000', + ) +}) diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index 8e6f167..798862b 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -22,6 +22,12 @@ export type FrameworkEvent = | { kind: 'bootstrap'; event: BootstrapEvent } /** The wrapped agent's own progress, forwarded verbatim (never gated on). */ | { kind: 'driver'; event: DriverEvent } + /** + * The generated app is booted and serving. Emitted after a successful run when + * a serve config is set: the app is kept running so the user can open it, and + * the dashboard shows a live preview link (torn down on Ctrl+C). + */ + | { kind: 'preview'; url: string; command: string } /** A framework-level log line. */ | { kind: 'log'; message: string } /** The run finished. `ok` is false when it threw. */ @@ -36,6 +42,8 @@ export function formatFrameworkEvent(event: FrameworkEvent): string { }` case 'session-update': return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}` + case 'preview': + return `▶ your app is running at ${event.url}` case 'log': return ` ${event.message}` case 'driver': diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 891d4b9..909004f 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -53,6 +53,7 @@ export { type RunFrameworkResult, type DeployDecision, type ServeConfig, + type AppPreview, } from './run.js' export { hostExecutor, type HostExecutorOptions } from './host-exec.js' export { diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 6b050a5..1695e75 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -45,6 +45,13 @@ export interface ServeConfig { waitMs?: number /** Path to fetch once it is up. Default `/`. */ healthPath?: string + /** + * Keep the app serving after a successful run and hand back an + * {@link AppPreview} the caller must {@link AppPreview.stop}. Default `false`: + * the serve gate boots the app only to check it, then tears it down. The CLI + * sets this so the dashboard can show a live preview link until Ctrl+C. + */ + keepAlive?: boolean } /** Options for {@link runFramework}. */ @@ -88,12 +95,32 @@ export interface RunFrameworkOptions { onEvent?: (event: FrameworkEvent) => void } +/** + * A running instance of the generated app, handed back so the caller can show a + * live preview link and keep it up until the user is done (then {@link stop}). + */ +export interface AppPreview { + /** The localhost URL the app is served at. */ + url: string + /** The command that started it (e.g. `npm run dev`). */ + command: string + /** Stop the app and free its runner. Idempotent. */ + stop(): Promise +} + /** What a run returns. */ export interface RunFrameworkResult { result: BootstrapResult detection: FrameworkDetection events: FrameworkEvent[] ledger: DecisionLedger + /** + * The generated app, left running when a {@link ServeConfig} was supplied and + * the run finished. The caller owns its lifecycle: show {@link AppPreview.url}, + * then call {@link AppPreview.stop} (e.g. on Ctrl+C). Absent when no serve + * config was set or the app could not be booted. + */ + preview?: AppPreview } /** @@ -186,6 +213,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise void, +): Promise { + if (!runner.start || !runner.preview) return undefined + let proc: Awaited>> | undefined + try { + proc = await runner.start(serve.command) + const { url } = await runner.preview({ + port: serve.port ?? 3000, + waitMs: serve.waitMs ?? 15_000, + }) + emit({ kind: 'preview', url, command: serve.command }) + let stopped = false + return { + url, + command: serve.command, + stop: async () => { + if (stopped) return + stopped = true + try { + await proc?.stop() + } finally { + await runner.dispose() + } + }, + } + } catch (err) { + emit({ kind: 'log', message: `preview: could not boot the app (${err instanceof Error ? err.message : String(err)})` }) + // Leave cleanup to the caller's finally (runner.dispose stops leftovers). + return undefined } } diff --git a/packages/framework/src/serve-gate.test.ts b/packages/framework/src/serve-gate.test.ts index ae84893..9c073a4 100644 --- a/packages/framework/src/serve-gate.test.ts +++ b/packages/framework/src/serve-gate.test.ts @@ -35,7 +35,7 @@ test('serve gate: production-grade only when the agent review AND the real serve // The "app the agent built": a tiny server that boots on `port`. await writeFile(join(dir, 'server.js'), `require('http').createServer((_,res)=>res.end('ok')).listen(${port})\n`) const events: FrameworkEvent[] = [] - const { result } = await runFramework({ + const { result, preview } = await runFramework({ intent: 'a tiny http service', driver: new FakeDriver({ turns: CLEAN_REVIEW }), cwd: dir, @@ -46,6 +46,42 @@ test('serve gate: production-grade only when the agent review AND the real serve assert.equal(result.productionGrade, true) assert.equal(result.passes, 1) assert.ok(events.some(e => e.kind === 'log' && e.message.startsWith('serve:'))) + // Without keepAlive the app is torn down after the check: no lingering handle. + assert.equal(preview, undefined) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('serve gate: the app is left running with a preview link after a successful run', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) + const port = await freePort() + try { + await writeFile(join(dir, 'server.js'), `require('http').createServer((_,res)=>res.end('hi from app')).listen(${port})\n`) + const events: FrameworkEvent[] = [] + const { result, preview } = await runFramework({ + intent: 'a tiny http service', + driver: new FakeDriver({ turns: CLEAN_REVIEW }), + cwd: dir, + signals: FAKE_SIGNALS, + serve: { command: 'node server.js', port, waitMs: 5000, keepAlive: true }, + onEvent: e => events.push(e), + }) + assert.equal(result.productionGrade, true) + + // The app is handed back running, with a preview event on the stream. + assert.ok(preview, 'expected a live preview') + assert.equal(preview!.url, `http://localhost:${port}`) + assert.ok(events.some(e => e.kind === 'preview' && e.url === preview!.url)) + + // It actually serves right now. + const res = await fetch(preview!.url) + assert.equal(res.status, 200) + assert.equal(await res.text(), 'hi from app') + + // stop() tears it down; the port stops answering. + await preview!.stop() + await assert.rejects(fetch(preview!.url)) } finally { await rm(dir, { recursive: true, force: true }) }