From 7afe994759abb54755c279e2ceec8cf896105cdf Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 3 Jul 2026 22:22:35 +0300 Subject: [PATCH] feat(framework): keep the app running after --serve and show a preview link Once the boot-and-serve gate passes, boot the generated app once more and leave it serving so the user can open it. The dashboard shows an 'open your app' banner and the terminal prints the URL, live until the run is stopped (Ctrl+C tears the app down). runFramework returns an optional preview handle ({ url, command, stop() }); persistence is opt-in via ServeConfig.keepAlive (the CLI sets it) so a programmatic run never leaks a process. Closes #176 --- .changeset/framework-app-preview.md | 5 ++ packages/framework/src/cli.ts | 18 +++-- packages/framework/src/dashboard/page.ts | 16 +++++ packages/framework/src/events.test.ts | 7 ++ packages/framework/src/events.ts | 8 +++ packages/framework/src/index.ts | 1 + packages/framework/src/run.ts | 82 ++++++++++++++++++++++- packages/framework/src/serve-gate.test.ts | 38 ++++++++++- 8 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 .changeset/framework-app-preview.md 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 }) }