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
5 changes: 5 additions & 0 deletions .changeset/framework-app-preview.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Options:
--permission-mode <mode> Claude Code permission mode: default | acceptEdits |
bypassPermissions | plan (default: acceptEdits).
--dangerously-skip-permissions Bypass all agent permission checks (sandboxes only).
--serve <cmd> Gate the loop on the app actually running (e.g. "npm run dev").
--serve <cmd> 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 <cmd> Install command before serving (e.g. "npm install").
--serve-build <cmd> Build command before serving (e.g. "npm run build").
--serve-port <n> Port the app listens on (default: 3000).
Expand Down Expand Up @@ -283,6 +284,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
const serve: ServeConfig | undefined = opts.serve
? {
command: opts.serve,
// The CLI keeps the dashboard (and app) up until Ctrl+C, so leave the app
// serving with a preview link once the run succeeds.
keepAlive: true,
...(opts.serveInstall ? { install: opts.serveInstall } : {}),
...(opts.serveBuild ? { build: opts.serveBuild } : {}),
...(opts.servePort !== undefined ? { port: opts.servePort } : {}),
Expand Down Expand Up @@ -321,16 +325,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}

try {
const { result } = await runFramework(runOpts)
const { result, preview } = await runFramework(runOpts)
io.out(
result.productionGrade
? `\n✓ production-grade in ${result.passes} pass(es).`
: `\n• prototype ready${result.stoppedEarly ? ` (stopped with ${result.blockers.length} blocker(s))` : ''}.`,
)
if (dashboard) {
io.out(`\nDashboard still live at ${dashboard.url}. Press Ctrl+C to exit.`)
if (preview) io.out(`\n▶ Your app is running at ${preview.url} — open it in a browser.`)
// Stay up while the dashboard and/or the app are live, then tear both down.
if (dashboard || preview) {
if (dashboard) io.out(`\nDashboard still live at ${dashboard.url}. Press Ctrl+C to exit.`)
else io.out(`\nPress Ctrl+C to stop the app.`)
await waitForInterrupt()
await dashboard.close()
if (preview) await preview.stop()
await dashboard?.close()
}
return 0
} catch (err) {
Expand Down
16 changes: 16 additions & 0 deletions packages/framework/src/dashboard/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export function dashboardHtml(title: string): string {
#session-link { margin-left: auto; font-size: 12px; color: #7b8496; }
#session-link code { color: #b7c0d0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#status { font-size: 12px; color: #7b8496; }
#app-banner { display: flex; align-items: center; gap: 8px; padding: 10px 20px;
background: #0f2417; border-bottom: 1px solid #1c3a28; font-size: 13px; }
#app-banner .dot { color: #67d98f; }
#app-banner a { color: #67d98f; font-weight: 600; }
#app-banner .run { color: #6f8a79; font-size: 12px; }
main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 18px 20px; max-width: 1100px; }
section { background: #10141d; border: 1px solid #1c2230; border-radius: 10px; padding: 14px 16px; }
section h2 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase;
Expand Down Expand Up @@ -56,6 +61,11 @@ export function dashboardHtml(title: string): string {
<span id="session-link"></span>
<span id="status">●</span>
</header>
<div id="app-banner" hidden>
<span class="dot">▶</span>
<span>Your app is running at <a id="app-link" href="#" target="_blank" rel="noopener">…</a></span>
<span class="run">live until you stop the run</span>
</div>
<main>
<section id="stack-panel">
<h2>Stack &amp; rationale</h2>
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/framework/src/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
8 changes: 8 additions & 0 deletions packages/framework/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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':
Expand Down
1 change: 1 addition & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
82 changes: 80 additions & 2 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand Down Expand Up @@ -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<void>
}

/** 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
}

/**
Expand Down Expand Up @@ -186,6 +213,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
: driverChecklist(session)

const ledger = new DecisionLedger()
let preview: AppPreview | undefined
try {
const bootstrap = new Bootstrap({
ledger,
Expand All @@ -206,13 +234,63 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
},
})
const result = await bootstrap.run()
// The serve gate boots the app only to check it, then stops it. When the
// caller opts in (keepAlive), boot it once more after success and leave it up
// so the user can open it; the caller owns tearing it down (Ctrl+C). Failure
// to boot is non-fatal. Default off, so a programmatic run never leaks a
// process a caller that ignores `preview` would never stop.
if (runner && s?.keepAlive) preview = await startAppPreview(runner, s, emit)
emit({ kind: 'end', ok: true })
return { result, detection, events, ledger }
return { result, detection, events, ledger, ...(preview ? { preview } : {}) }
} catch (err) {
emit({ kind: 'end', ok: false, detail: err instanceof Error ? err.message : String(err) })
throw err
} finally {
await session.dispose()
if (runner) await runner.dispose()
// Keep the runner alive only when it owns a live preview handed to the caller.
if (runner && !preview) await runner.dispose()
}
}

/**
* Boot the generated app in the adopted runner and keep it serving. Reuses the
* same {@link ServeConfig} the serve gate used (deps are already installed from
* the gate), so this only `start`s the server and `preview`s the port. Returns a
* handle that stops the app and frees the runner; on any failure it narrates and
* returns `undefined` so a run never fails just because the demo preview didn't
* come up.
*/
async function startAppPreview(
runner: LocalRunnerSession,
serve: ServeConfig,
emit: (event: FrameworkEvent) => void,
): Promise<AppPreview | undefined> {
if (!runner.start || !runner.preview) return undefined
let proc: Awaited<ReturnType<NonNullable<LocalRunnerSession['start']>>> | 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
}
}
38 changes: 37 additions & 1 deletion packages/framework/src/serve-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 })
}
Expand Down
Loading