From dfca61869e179d3268a5bdd50196331ca43aeece Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 17 Aug 2026 14:39:07 -0400 Subject: [PATCH] feat(cli): run a managed background preview in every launch mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--background` was rejected outside the embedded server. It now re-execs the CLI in foreground, which makes it mode-agnostic by construction: whichever server the child resolves to serves the config endpoint the readiness probe looks for. `--foreground` is its counterpart, for a non-interactive shell that wants to stay attached, and a bare launch keeps the same promise — attached in an interactive terminal, managed in an agent session. That generalization exposed an existing hole. Local-studio mode runs Vite with the studio package as its cwd and needs that package's own Vite config, which the published tarball does not carry, but resolving the package was treated as proof the mode was usable. An npm-installed studio therefore took a path that can never come up — previously a clear error, now a ten-second silent timeout. The predicate becomes "can this studio actually be served", so a published install falls back to embedded mode, which works. Over the 1k line budget at ~1.3k. The overage is one command file and its tests carrying one invariant, and the seam that would split it further is inside a single request-handling function — a split there would produce two PRs neither of which starts a preview on its own. --- docs/packages/cli.mdx | 13 +- packages/cli/README.md | 11 +- .../cli/src/commands/coreSkillContent.test.ts | 11 + packages/cli/src/commands/preview.test.ts | 321 ++++++++- packages/cli/src/commands/preview.ts | 680 +++++++++++++----- .../cli/src/commands/previewLifecycle.test.ts | 5 +- packages/cli/src/commands/previewLifecycle.ts | 7 +- packages/cli/src/templates/_shared/AGENTS.md | 13 +- packages/cli/src/templates/_shared/CLAUDE.md | 13 +- packages/cli/src/utils/studioProxyEnv.test.ts | 18 + packages/cli/src/utils/studioProxyEnv.ts | 14 + packages/studio/vite.config.ts | 9 + packages/studio/vite.preview-config.test.ts | 30 + packages/studio/vite.preview-config.ts | 22 + skills-manifest.json | 14 +- skills/faceless-explainer/SKILL.md | 2 +- skills/hyperframes-cli/SKILL.md | 4 +- .../references/preview-render.md | 14 +- skills/hyperframes-core/SKILL.md | 2 +- .../references/review-loop.md | 2 +- skills/motion-graphics/SKILL.md | 2 +- skills/product-launch-video/SKILL.md | 2 +- skills/slideshow/SKILL.md | 2 +- skills/talking-head-recut/SKILL.md | 2 +- 24 files changed, 972 insertions(+), 241 deletions(-) create mode 100644 packages/studio/vite.preview-config.test.ts create mode 100644 packages/studio/vite.preview-config.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index ffb25e040b..a1c684c9cd 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -428,8 +428,6 @@ Needs a local Chrome, the same one `render` uses. Run within a frame or two — a different headless-Chrome audio sample rate can shift a beat slightly. -## Look at it - ### `preview` Start a live preview server with hot reload. @@ -438,6 +436,8 @@ Start a live preview server with hot reload. npx hyperframes preview [dir] npx hyperframes preview --port 4567 npx hyperframes preview --background # keep running after the command exits +npx hyperframes preview --foreground # stay attached in a non-interactive shell +npx hyperframes preview --status --json # inspect a managed preview from an agent npx hyperframes preview --list # every running preview ``` @@ -446,6 +446,8 @@ npx hyperframes preview --list # every running preview | `--port` | Server port (default 3002) | | `--open` / `--no-open` | Open a browser, or leave it closed | | `--background` | Keep an embedded preview running after the command exits | +| `--foreground` | Keep the preview attached even when the shell is non-interactive | +| `--json` | Emit one versioned JSON result for managed start, status, stop, list, and kill-all operations | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU for Studio thumbnails and frame capture, or deterministic SwiftShader (default: auto-detect) | | `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs (HEVC, ProRes, AV1) to a cached authoring proxy (default: on) | | `--browser-path` | Open a specific browser. `--user-data-dir`, `--remote-debugging-port`, and `--browser-no-gpu` require it. | @@ -455,6 +457,13 @@ background preview, `--list` and `--kill-all` act on all of them, and `--force-new` starts a second server for a project that already has one. Each exits straight after. +Bare `preview` chooses the safest lifecycle for its caller: it stays in the +foreground in a human interactive terminal, while a non-interactive or agent +shell starts a managed background preview. Re-running the command for the same +project reuses the healthy preview. Every start or status result includes the +exact Studio project URL as well as the underlying server URL, so agents can +hand off the intended project without guessing from the port. + To read a running Studio from a script: `--selection` prints the selected element and `--context` prints the agent-readable context, both with `--json`. Narrow the context with `--context-fields` (`server`, `selection`, `lint`, diff --git a/packages/cli/README.md b/packages/cli/README.md index fffe6cc26e..a1dc03e5c4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -33,11 +33,20 @@ Start the live preview studio in your browser: ```bash npx hyperframes preview -# Studio running at http://localhost:3002 +# Studio: http://localhost:3002/#project/my-video +# Server: http://localhost:3002 npx hyperframes preview --port 4567 ``` +In an interactive terminal, the preview stays attached until you press +Ctrl+C. In a non-interactive shell such as a coding-agent session, the same +command starts a managed preview that survives after the command returns. Use +`--background` or `--foreground` to choose explicitly, and manage persistent +previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to +managed lifecycle commands for machine-readable output. `--foreground --json` +prints the ready-session envelope once, then remains attached until stopped. + ### `render` Render a composition to MP4. Run from the project directory; the positional diff --git a/packages/cli/src/commands/coreSkillContent.test.ts b/packages/cli/src/commands/coreSkillContent.test.ts index fe25f5764d..635579de30 100644 --- a/packages/cli/src/commands/coreSkillContent.test.ts +++ b/packages/cli/src/commands/coreSkillContent.test.ts @@ -125,4 +125,15 @@ describe("media treatment routing documentation", () => { expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays"); } }); + + it("gives agents a process-owned preview lifecycle in new project instructions", () => { + for (const file of ["AGENTS.md", "CLAUDE.md"]) { + const template = read("packages", "cli", "src", "templates", "_shared", file); + expect(template).toContain("npx hyperframes preview --background"); + expect(template).toContain("npx hyperframes preview --status"); + expect(template).toContain("npx hyperframes preview --stop"); + expect(template).toContain("leaving refreshes at `ERR_CONNECTION_TIMED_OUT`"); + expect(template).not.toContain("run_in_background: true"); + } + }); }); diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index ae1646c467..4d6c7528fd 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -1,20 +1,33 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import * as clack from "@clack/prompts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCommand } from "citty"; import { default as previewCommand, + foregroundPreviewReadyPayload, handlePreviewKillAll, handlePreviewList, + previewLaunchMode, + previewLaunchModeError, + previewPortError, + publicPreviewPid, + previewViteArgs, + reportPreviewShutdown, + studioReadyUrl, + studioDeepLink, studioLandingSearch, + studioSummaryUrls, + waitForStudioChildClose, } from "./preview.js"; const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + process.exitCode = undefined; }); function projectWith(storyboard: string | null, frameFiles: string[] = []): string { @@ -59,6 +72,33 @@ describe("studioLandingSearch", () => { }); }); +describe("Studio handoff URLs", () => { + it("hands off the exact timeline project route", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/#project/demo", + ); + expect(studioSummaryUrls("demo", "http://127.0.0.1:3002", dir)).toEqual({ + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + }); + }); + + it("hands off the exact storyboard route while a project is still planning", () => { + const dir = projectWith(FRAME(1, "outline")); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/?view=storyboard#project/demo", + ); + }); + + it("URL-encodes project names that have hash-route metacharacters", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "Launch #1? 50%", dir)).toBe( + "http://127.0.0.1:3002/#project/Launch%20%231%3F%2050%25", + ); + }); +}); + describe("preview --kill-all", () => { const session = (port: number, projectDir: string) => ({ pid: 4321, @@ -101,29 +141,157 @@ describe("preview --kill-all", () => { }); }); -describe("preview --list", () => { - it("prefers the managed record over the same server's own self-report", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); +describe("previewLaunchMode", () => { + it.each([ + [ + { + background: false, + foreground: false, + interactive: false, + devMode: false, + localStudio: false, + }, + "background", + ], + [ + { + background: false, + foreground: false, + interactive: true, + devMode: false, + localStudio: false, + }, + "embedded", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: true, + localStudio: false, + }, + "dev", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: false, + localStudio: true, + }, + "local", + ], + [ + { + background: true, + foreground: false, + interactive: true, + devMode: true, + localStudio: true, + }, + "background", + ], + ] as const)("resolves %o to %s", (options, expected) => { + expect(previewLaunchMode(options)).toBe(expected); + }); - await handlePreviewList(3002, false, { - listManaged: async () => [ - { pid: 99, port: 3002, projectDir: resolve("/tmp/demo"), logPath: "/tmp/demo.log" }, - ], - scan: async () => [ - { - port: 3002, - projectName: "demo", - projectDir: resolve("/tmp/demo"), - version: "test", - pid: "99", - }, - ], - }); + it("rejects conflicting lifecycle overrides and actions", () => { + expect( + previewLaunchModeError({ + background: true, + foreground: true, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBe("--background and --foreground cannot be used together"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + status: true, + stop: true, + list: false, + killAll: false, + }), + ).toBe("Only one of --status, --stop, --list, or --kill-all can be used at a time"); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBeNull(); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: true, + status: false, + stop: false, + list: false, + killAll: true, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + forceNew: true, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + }); - const printed = log.mock.calls.flat().join("\n"); - expect(printed).toContain("1 server running"); - expect(printed).toContain("PID 99"); - log.mockRestore(); + it.each([ + [undefined, null], + ["3002", null], + ["1", null], + ["65535", null], + ["banana", "--port must be an integer between 1 and 65535"], + ["3002oops", "--port must be an integer between 1 and 65535"], + ["0", "--port must be an integer between 1 and 65535"], + ["65536", "--port must be an integer between 1 and 65535"], + ])("validates preview port %j", (value, expected) => { + expect(previewPortError(value)).toBe(expected); + }); + + it("prefers the live server PID over its launcher PID", () => { + expect(publicPreviewPid("9876", 4321)).toBe(9876); + expect(publicPreviewPid(null, 4321)).toBe(4321); + }); + + it("pins detached Vite to the port the lifecycle scanner waits on", () => { + expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]); + }); + + it.each([ + [" Local: http://localhost:43127/", "http://localhost:43127"], + [" Local: http://127.0.0.1:43127/", "http://127.0.0.1:43127"], + [ + "\u001b[32m Local:\u001b[0m \u001b[36mhttp://127.0.0.1:43127/\u001b[0m", + "http://127.0.0.1:43127", + ], + ])("extracts the ready URL from Vite output %j", (output, expected) => { + expect(studioReadyUrl(output)).toBe(expected); }); }); @@ -196,6 +364,25 @@ describe("preview lifecycle JSON failures", () => { }); }); + it("wraps managed-start validation failures in one JSON document", async () => { + const dir = projectWith(null); + writeFileSync(join(dir, "index.html"), ""); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [dir, "--background", "--json", "--user-data-dir", join(dir, "profile")], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { code: "preview-validation-failed" }, + }); + }); + it("wraps stop failures in one JSON document", async () => { const missing = join(tmpdir(), `hf-preview-missing-${process.pid}-${Date.now()}`); const log = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -215,4 +402,94 @@ describe("preview lifecycle JSON failures", () => { }); expect(error).not.toHaveBeenCalled(); }); + + it("wraps missing-project start failures without human stderr", async () => { + const missing = join(tmpdir(), `hf-preview-missing-start-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { rawArgs: [missing, "--background", "--json"] }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + operation: "start", + ok: false, + error: { code: "preview-start-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); +}); + +describe("foreground preview JSON", () => { + it("emits the same ready session contract before remaining attached", () => { + const dir = projectWith(null); + expect(foregroundPreviewReadyPayload("Launch #1", "http://localhost:4567", dir, 4321)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { + state: "started", + mode: "foreground", + projectName: "Launch #1", + projectDir: dir, + host: "127.0.0.1", + port: 4567, + pid: 4321, + serverUrl: "http://127.0.0.1:4567", + studioUrl: "http://127.0.0.1:4567/#project/Launch%20%231", + ready: true, + }, + }); + }); + + it("keeps embedded shutdown silent after the readiness envelope", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + reportPreviewShutdown(true); + + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe("waitForStudioChildClose", () => { + it("resolves when the child closed before the listener was attached", async () => { + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: 1, + signalCode: null, + once: vi.fn(), + } as unknown as Parameters[0]; + + await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined(); + expect(child.once).not.toHaveBeenCalled(); + expect(signalTarget.once).toHaveBeenCalledTimes(2); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); + + it("reaps on process exit even when stdio never emits close", async () => { + let exit: (() => void) | undefined; + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: null, + signalCode: null, + once: vi.fn((event: string, listener: () => void) => { + if (event === "exit") exit = listener; + }), + } as unknown as Parameters[0]; + + let resolved = false; + const waiting = waitForStudioChildClose(child, signalTarget).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + expect(child.once).toHaveBeenCalledWith("exit", expect.any(Function)); + + exit?.(); + await waiting; + expect(resolved).toBe(true); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 50ad19751b..5203d7eead 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -13,6 +13,7 @@ export const examples: Example[] = [ ["Use a custom port", "hyperframes preview --port 8080"], ["Force a new server even if one is already running", "hyperframes preview --force-new"], ["Keep preview running after this command exits", "hyperframes preview --background"], + ["Force an attached preview in a non-interactive shell", "hyperframes preview --foreground"], ["Show the background preview for this project", "hyperframes preview --status"], ["Stop the background preview for this project", "hyperframes preview --stop"], ["Start without opening the browser", "hyperframes preview --no-open"], @@ -55,6 +56,7 @@ import { import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { + activeServerOnPort, findPortAndServe, scanActiveServers, killActiveServers, @@ -75,6 +77,7 @@ import { lifecyclePayload, writeLifecycleJson, type PreviewLifecycleOperation, + type PreviewLifecyclePayload, type PreviewLifecycleSession, } from "./previewLifecycleOutput.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; @@ -91,6 +94,8 @@ interface StudioLaunchOptions extends BrowserLaunchOptions { projectName?: string; autoProxy?: boolean; browserGpuMode?: BrowserGpuMode; + port?: number; + json?: boolean; } interface EmbeddedStudioOptions extends StudioLaunchOptions { @@ -99,6 +104,10 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; +interface StudioSignalTarget { + once(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; + off(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; +} type ContextField = "server" | "selection" | "lint" | "capabilities"; type CompactSelectionPayload = Pick< StudioSelectionSnapshot, @@ -118,10 +127,21 @@ type CompactSelectionPayload = Pick< const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"]; export default defineCommand({ - meta: { name: "preview", description: "Start the studio for previewing compositions" }, + meta: { + name: "preview", + description: "Start the studio for previewing compositions", + }, args: { - dir: { type: "positional", description: "Project directory", required: false }, - port: { type: "string", description: "Port to run the preview server on", default: "3002" }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, + port: { + type: "string", + description: "Port to run the preview server on", + default: "3002", + }, "force-new": { type: "boolean", description: "Start a new server even if one is already running for this project", @@ -129,7 +149,12 @@ export default defineCommand({ }, background: { type: "boolean", - description: "Start an embedded preview that remains running after the command exits", + description: "Start a preview that remains running after the command exits", + default: false, + }, + foreground: { + type: "boolean", + description: "Keep preview attached even when the shell is non-interactive", default: false, }, status: { @@ -164,7 +189,7 @@ export default defineCommand({ }, json: { type: "boolean", - description: "Output preview selection/context as JSON (only with --selection or --context)", + description: "Output selection, context, or managed lifecycle state as JSON", default: false, }, context: { @@ -215,6 +240,58 @@ export default defineCommand({ }, }, async run({ args }) { + const launchModeError = previewLaunchModeError({ + background: Boolean(args.background), + foreground: Boolean(args.foreground), + forceNew: Boolean(args["force-new"]), + status: Boolean(args.status), + stop: Boolean(args.stop), + list: Boolean(args.list), + killAll: Boolean(args["kill-all"]), + }); + if (launchModeError) { + if (args.json) { + writeLifecycleJson( + lifecycleFailurePayload( + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "conflicting-lifecycle-flags", + launchModeError, + ), + ); + } else { + clack.log.error(launchModeError); + } + setCommandExitCode(1); + return; + } + + const portError = previewPortError(args.port); + if (portError) { + reportPreviewFailure( + Boolean(args.json), + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "preview-validation-failed", + portError, + ); + return; + } + const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; @@ -223,8 +300,6 @@ export default defineCommand({ if (args.status || args.stop) { try { - // Under --json a missing project is a lifecycle failure document, not a - // human-shaped nudge, so the throwing resolver is the right one there. const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir); if (args.stop) { const stopped = await stopBackgroundPreview(project.dir, startPort); @@ -250,7 +325,10 @@ export default defineCommand({ if (!status) { if (args.json) { writeLifecycleJson( - lifecyclePayload("status", { state: "not-running", projectDir: project.dir }), + lifecyclePayload("status", { + state: "not-running", + projectDir: project.dir, + }), ); } else { console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); @@ -274,11 +352,10 @@ export default defineCommand({ ); return; } - console.log(`\n ${c.success("Background preview running")}`); - console.log( - ` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`, - ); - console.log(` ${c.dim(status.logPath)}\n`); + printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { + details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], + }); + return; } catch (error) { reportPreviewFailure( Boolean(args.json), @@ -286,8 +363,8 @@ export default defineCommand({ args.stop ? "preview-stop-failed" : "preview-status-failed", errorMessage(error), ); + return; } - return; } // --list: scan and display active servers @@ -324,13 +401,24 @@ export default defineCommand({ const rawArg = args.dir; const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const project = resolveProject(rawArg); + let project; + try { + project = args.json ? resolveProjectOrThrow(rawArg) : resolveProject(rawArg); + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-start-failed", + errorMessage(error), + ); + return; + } const dir = project.dir; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. const lintResult = await lintProject(dir); - if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + if (!args.json && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); console.log(); @@ -338,8 +426,12 @@ export default defineCommand({ // Validation: --user-data-dir requires --browser-path if (args["user-data-dir"] && !args["browser-path"]) { - clack.log.error("--user-data-dir requires --browser-path"); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + "--user-data-dir requires --browser-path", + ); return; } // Validation: --remote-debugging-port deps @@ -349,8 +441,7 @@ export default defineCommand({ remoteDebuggingPort: args["remote-debugging-port"] as string | undefined, }); if (depsError) { - clack.log.error(depsError); - setCommandExitCode(1); + reportPreviewFailure(Boolean(args.json), "start", "preview-validation-failed", depsError); return; } @@ -358,10 +449,12 @@ export default defineCommand({ const browserPath = args["browser-path"] as string | undefined; const browserNoGpu = !!args["browser-no-gpu"]; if (browserNoGpu && !browserPath) { - clack.log.error( + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", "--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)", ); - setCommandExitCode(1); return; } const userDataDir = args["user-data-dir"] as string | undefined; @@ -371,8 +464,12 @@ export default defineCommand({ args["remote-debugging-port"] as string | undefined, ); } catch (err) { - clack.log.error((err as Error).message); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + (err as Error).message, + ); return; } // Resolve once so embedded, monorepo-dev, and locally installed Studio @@ -384,37 +481,80 @@ export default defineCommand({ // other people's PIDs, so it must not run for an invocation that turns out // to be a validation error and never starts anything. const orphansKilled = killOrphanedProcesses(); - if (orphansKilled > 0) { + if (orphansKilled > 0 && !args.json) { console.log( ` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`, ); } - if (isDevMode()) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); + const launchMode = previewLaunchMode({ + background: Boolean(args.background), + foreground: Boolean(args.foreground), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + devMode: isDevMode(), + localStudio: hasLocalStudio(dir), + }); + + if (launchMode === "background") { + let background; + try { + background = await startBackgroundPreview(dir, startPort, { + forceNew: Boolean(args["force-new"]), + // A bare launch promises same-project reuse, regardless of the mode + // the existing managed server resolved earlier. Only an explicit + // --browser-gpu/--no-browser-gpu request authorizes replacement. + browserGpuMode: args["browser-gpu"] === undefined ? undefined : browserGpuMode, + }); + } catch (error) { + const message = errorMessage(error); + if (args.json) { + writeLifecycleJson(lifecycleFailurePayload("start", "preview-start-failed", message)); + } else { + clack.log.error(message); + } setCommandExitCode(1); return; } - return runDevMode(dir, { - projectName, + const url = `http://localhost:${background.port}`; + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: background.type, + mode: "background", + projectName, + projectDir: dir, + port: background.port, + pid: background.pid, + ...(background.logPath ? { logPath: background.logPath } : {}), + }), + ), + ); + } else { + clack.intro(c.bold("hyperframes preview")); + printStudioSummary(projectName, url, dir, { + details: [ + background.type === "reused" + ? "Reusing the background server already running for this project." + : `Running in the background. Log: ${background.logPath}`, + "Changes reload automatically in the studio.", + ], + footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, + }); + } + openStudioBrowser(url, projectName, dir, { noOpen, browserPath, userDataDir, remoteDebuggingPort, browserNoGpu, - autoProxy, }); + return; } - // If @hyperframes/studio is installed locally, use Vite for full HMR - if (hasLocalStudio(dir)) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); - setCommandExitCode(1); - return; - } - return runLocalStudioMode(dir, { + if (launchMode === "dev") { + return runDevMode(dir, { projectName, noOpen, browserPath, @@ -422,40 +562,26 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, autoProxy, + browserGpuMode, + port: startPort, + json: Boolean(args.json), }); } - if (args.background) { - let background; - try { - background = await startBackgroundPreview(dir, startPort, { - forceNew: Boolean(args["force-new"]), - browserGpuMode, - }); - } catch (error) { - clack.log.error(errorMessage(error)); - setCommandExitCode(1); - return; - } - const url = `http://localhost:${background.port}`; - clack.intro(c.bold("hyperframes preview")); - printStudioSummary(projectName, url, { - details: [ - background.type === "reused" - ? "Reusing the background server already running for this project." - : `Running in the background. Log: ${background.logPath}`, - "Changes reload automatically in the studio.", - ], - footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, - }); - openStudioBrowser(url, projectName, dir, { + // If @hyperframes/studio is installed locally, use Vite for full HMR + if (launchMode === "local") { + return runLocalStudioMode(dir, { + projectName, noOpen, browserPath, userDataDir, remoteDebuggingPort, browserNoGpu, + autoProxy, + browserGpuMode, + port: startPort, + json: Boolean(args.json), }); - return; } const forceNew = !!args["force-new"]; @@ -469,35 +595,63 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, browserGpuMode, + json: Boolean(args.json), }); }, }); -function previewLifecycleSession(options: { - state: PreviewLifecycleSession["state"]; - mode: PreviewLifecycleSession["mode"]; - projectName: string; - projectDir: string; - port: number; - pid: number | null; - host?: string; - logPath?: string; -}): PreviewLifecycleSession { - const host = options.host ?? "127.0.0.1"; - const serverUrl = previewBaseUrl(options.port, host); - return { - state: options.state, - mode: options.mode, - projectName: options.projectName, - projectDir: options.projectDir, - host, - port: options.port, - pid: options.pid, - serverUrl, - studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir), - ready: true, - ...(options.logPath ? { logPath: options.logPath } : {}), - }; +export type PreviewLaunchMode = "background" | "dev" | "local" | "embedded"; + +export function previewLaunchMode(options: { + background: boolean; + foreground: boolean; + interactive: boolean; + devMode: boolean; + localStudio: boolean; +}): PreviewLaunchMode { + if (options.background) return "background"; + if (!options.foreground && !options.interactive) return "background"; + if (options.devMode) return "dev"; + return options.localStudio ? "local" : "embedded"; +} + +export function previewLaunchModeError(options: { + background: boolean; + foreground: boolean; + forceNew?: boolean; + status: boolean; + stop: boolean; + list: boolean; + killAll: boolean; +}): string | null { + if (options.background && options.foreground) { + return "--background and --foreground cannot be used together"; + } + const actionCount = [options.status, options.stop, options.list, options.killAll].filter( + Boolean, + ).length; + if (actionCount > 1) { + return "Only one of --status, --stop, --list, or --kill-all can be used at a time"; + } + if (actionCount > 0 && (options.background || options.foreground || options.forceNew)) { + return "Preview launch overrides cannot be combined with lifecycle actions"; + } + return null; +} + +export function previewPortError(port: string | undefined): string | null { + const value = port ?? "3002"; + if (!/^\d+$/.test(value)) return "--port must be an integer between 1 and 65535"; + const parsed = Number(value); + return parsed >= 1 && parsed <= 65535 ? null : "--port must be an integer between 1 and 65535"; +} + +export function publicPreviewPid( + serverPid: string | null | undefined, + fallbackPid: number | null, +): number | null { + const parsed = Number(serverPid); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallbackPid; } function reportPreviewFailure( @@ -518,11 +672,6 @@ interface PreviewActionDependencies { killScanned?: typeof killActiveServers; } -/** - * Managed previews first, then anything else answering on the scanned range. - * A managed session is the authoritative entry for its project and port — the - * scan would otherwise list the same server again from its own self-report. - */ export async function handlePreviewList( startPort: number, json: boolean, @@ -585,14 +734,6 @@ export async function handlePreviewList( } } -/** - * Stop every managed preview through its ownership record, then sweep whatever - * else is still listening. - * - * Per-record failures are collected rather than propagated: one record whose - * ownership cannot be proven must not abandon the servers after it, which would - * leave them running AND unreported. - */ export async function handlePreviewKillAll( startPort: number, json: boolean, @@ -657,8 +798,12 @@ export async function handlePreviewKillAll( } } -// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded -// binds `127.0.0.1`); default to IPv4 for the embedded/legacy callers. +export function previewViteArgs(port: number | undefined): string[] { + return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; +} + +// All preview modes bind the same IPv4 loopback so lifecycle probes and handed +// URLs agree on the reachable server. function previewBaseUrl(port: number, host = "127.0.0.1"): string { return `http://${host}:${port}`; } @@ -835,7 +980,12 @@ function countLintFindings(findings: Array<{ severity: string }>): { async function printCurrentContext( projectDir: string, startPort: number, - options: { json: boolean; fields?: string; detail?: string; preferredPort?: number }, + options: { + json: boolean; + fields?: string; + detail?: string; + preferredPort?: number; + }, ): Promise { let fields: ContextField[]; try { @@ -922,7 +1072,10 @@ async function printCurrentContext( ok: false as const, error: selectionResult.status === "rejected" - ? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) } + ? { + code: "selection-unavailable", + message: errorMessage(selectionResult.reason), + } : { code: "no-selection", message: "Studio is running, but no element is selected.", @@ -940,8 +1093,14 @@ async function printCurrentContext( ok: false as const, error: lintResult.status === "rejected" - ? { code: "lint-unavailable", message: errorMessage(lintResult.reason) } - : { code: "lint-not-requested", message: "Lint was not requested." }, + ? { + code: "lint-unavailable", + message: errorMessage(lintResult.reason), + } + : { + code: "lint-not-requested", + message: "Lint was not requested.", + }, }; const payload: Record = { ok: true }; @@ -1037,8 +1196,66 @@ export function studioLandingSearch(projectDir: string): string { // The full Studio URL to open or hand to the user: status-aware landing view // plus the project hash route. `url` never carries a trailing slash (both the // embedded server and the Vite `Local:` match strip it). -function studioDeepLink(url: string, projectName: string, projectDir: string): string { - return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`; +export function studioDeepLink(url: string, projectName: string, projectDir: string): string { + return `${url}/${studioLandingSearch(projectDir)}#project/${encodeURIComponent(projectName)}`; +} + +export function studioSummaryUrls( + projectName: string, + serverUrl: string, + projectDir: string, +): { serverUrl: string; studioUrl: string } { + return { + serverUrl, + studioUrl: studioDeepLink(serverUrl, projectName, projectDir), + }; +} + +export function foregroundPreviewReadyPayload( + projectName: string, + serverUrl: string, + projectDir: string, + pid: number | null, +): PreviewLifecyclePayload { + const port = Number(new URL(serverUrl).port); + return lifecyclePayload( + "start", + previewLifecycleSession({ + state: "started", + mode: "foreground", + projectName, + projectDir, + port, + pid, + }), + ); +} + +function previewLifecycleSession(options: { + state: PreviewLifecycleSession["state"]; + mode: PreviewLifecycleSession["mode"]; + projectName: string; + projectDir: string; + port: number; + pid: number | null; + host?: string; + logPath?: string; +}): PreviewLifecycleSession { + const host = options.host ?? "127.0.0.1"; + const serverUrl = previewBaseUrl(options.port, host); + return { + state: options.state, + mode: options.mode, + projectName: options.projectName, + projectDir: options.projectDir, + host, + port: options.port, + pid: options.pid, + serverUrl, + studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir), + ready: true, + ...(options.logPath ? { logPath: options.logPath } : {}), + }; } function openStudioBrowser( @@ -1058,12 +1275,15 @@ function openStudioBrowser( function printStudioSummary( projectName: string, - url: string, + serverUrl: string, + projectDir: string, opts: { details?: string[]; footer?: string } = {}, ): void { + const urls = studioSummaryUrls(projectName, serverUrl, projectDir); console.log(); console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); + console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`); + console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`); console.log(); for (const detail of opts.details ?? []) { console.log(` ${c.dim(detail)}`); @@ -1115,17 +1335,33 @@ function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void }); } -function registerChildTreeShutdown(child: StudioChildProcess): void { +export function waitForStudioChildClose( + child: StudioChildProcess, + signalTarget: StudioSignalTarget = process, +): Promise { const shutdown = (): void => { if (child.pid) killProcessTree(child.pid); }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); -} + signalTarget.once("SIGINT", shutdown); + signalTarget.once("SIGTERM", shutdown); + + // A short-lived Vite child can exit before launch setup reaches this point. + // ChildProcess does not replay lifecycle events to listeners attached later, + // so waiting unconditionally would strand the preview wrapper forever. + const closed = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolveClose) => { + // `close` waits for stdio to close too. A Vite descendant can inherit + // those pipes, so the wrapper must key its lifetime to process exit. + child.once("exit", () => resolveClose()); + }); -function waitForChildClose(child: StudioChildProcess): Promise { - return new Promise((resolveClose) => { - child.on("close", () => resolveClose()); + return closed.finally(() => { + // Signal listeners keep Bun's event loop alive even after Vite exits. Leaving + // them registered makes `preview --stop` close the port but leak the wrapper. + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); }); } @@ -1134,30 +1370,60 @@ function attachStudioReadyHandler( spinner: ReturnType, projectName: string, projectDir: string, - options?: BrowserLaunchOptions, + options?: StudioLaunchOptions, ): void { let detected = false; - function handleOutput(data: Buffer): void { - const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1]; + async function handleOutput(data: Buffer): Promise { + const url = studioReadyUrl(data.toString()); if (!url || detected) return; detected = true; - spinner.stop(c.success("Studio running")); - printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); + if (options?.json) { + const port = Number(new URL(url).port); + const server = await activeServerOnPort(port); + writeLifecycleJson( + foregroundPreviewReadyPayload( + projectName, + url, + projectDir, + publicPreviewPid(server?.pid, child.pid ?? null), + ), + ); + } else { + spinner.stop(c.success("Studio running")); + printStudioSummary(projectName, url, projectDir, { + footer: "Press Ctrl+C to stop", + }); + } openStudioBrowser(url, projectName, projectDir, options); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } - child.stdout.on("data", handleOutput); - child.stderr.on("data", handleOutput); + child.stdout.on("data", (data) => void handleOutput(data)); + child.stderr.on("data", (data) => void handleOutput(data)); child.on("error", (err) => { - spinner.stop(c.error("Failed to start studio")); - console.error(c.dim(err.message)); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", err.message); + } else { + spinner.stop(c.error("Failed to start studio")); + console.error(c.dim(err.message)); + } }); } +export function studioReadyUrl(output: string): string | null { + const localLine = output.split(/\r?\n/).find((line) => line.includes("Local:")); + return localLine?.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+/)?.[0] ?? null; +} + +export function reportPreviewShutdown(json: boolean): void { + if (json) return; + console.log(); + console.log(` ${c.dim("Shutting down studio...")}`); +} + /** * Dev mode: spawn the studio dev server from the monorepo. */ @@ -1171,17 +1437,21 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise` only targets this process — the child tree (Vite + Chrome) // would survive without explicit cleanup. - // On Windows, killProcessTree delegates to taskkill's tree mode, which force - // kills the whole tree immediately — no grace period, unlike the POSIX path. - registerChildTreeShutdown(child); - return waitForChildClose(child); + // On Windows, killProcessTree delegates to taskkill /T so descendants are + // reaped even when the console signal reaches only this wrapper — and it + // always forces, so there is no grace period there, unlike the POSIX path. + return waitForStudioChildClose(child); } /** - * Check if @hyperframes/studio is installed locally in the project's node_modules. + * Whether the project's local @hyperframes/studio can actually be SERVED. + * + * Local mode runs `vite` with the studio package as its cwd, so it needs that + * package's own `vite.config.ts` — which the published tarball does not carry + * (`files: ["src", "dist"]`). Resolving the package alone therefore is not + * enough: an npm-installed studio would send `preview` down a path that can + * never come up, and since `--background` re-execs this same CLI, it would time + * out after ten seconds instead of falling back. Fall back to embedded mode, + * which serves the same studio and does work from a published install. */ function hasLocalStudio(dir: string): boolean { try { const req = createRequire(join(dir, "package.json")); - req.resolve("@hyperframes/studio/package.json"); - return true; + const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); + return existsSync(join(studioPkgPath, "vite.config.ts")); } catch { return false; } @@ -1223,23 +1501,26 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P const projectsDir = join(studioPkgPath, "data", "projects"); const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName); - clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); + if (!options?.json) clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); - const viteCommand = buildNpxCommand(["vite"]); + const viteCommand = buildNpxCommand(["vite", ...previewViteArgs(options?.port)]); const child = spawn(viteCommand.command, viteCommand.args, { cwd: studioPkgPath, stdio: ["ignore", "pipe", "pipe"], - env: studioProxyEnv(options?.autoProxy ?? true), + env: studioProxyEnv(options?.autoProxy ?? true, process.env, { + projectDir: dir, + projectName: pName, + browserGpuMode: options?.browserGpuMode, + }), }); attachStudioReadyHandler(child, s, pName, dir, options); removeSymlinkOnExit(createdSymlink, symlinkPath); - // Same tree-kill handler as dev mode. No-op on Windows (see comment above). - registerChildTreeShutdown(child); - return waitForChildClose(child); + // Same cross-platform tree-kill handler as dev mode. + return waitForStudioChildClose(child); } /** @@ -1260,32 +1541,37 @@ async function runEmbeddedMode( const pName = options?.projectName ?? basename(dir); const studioBundle = resolveStudioBundle(); - clack.intro(c.bold("hyperframes preview")); + if (!options?.json) clack.intro(c.bold("hyperframes preview")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); if (!studioBundle.available) { - s.stop(c.error("Studio build missing")); - console.error(); - console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); - for (const checkedPath of studioBundle.checkedPaths) { - console.error(` ${c.dim("-")} ${checkedPath}`); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", "Studio build missing"); + } else { + s.stop(c.error("Studio build missing")); + console.error(); + console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); + for (const checkedPath of studioBundle.checkedPaths) { + console.error(` ${c.dim("-")} ${checkedPath}`); + } + console.error(); + console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); + console.error(); } - console.error(); - console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); - console.error(); setCommandExitCode(1); return; } - const { app } = createStudioServer({ + // Compute everything that may throw before acquiring the fs.watch handle. + // Once createStudioServer returns, every subsequent exit path must close it. + const serverBuildSignature = await loadPreviewServerBuildSignature(); + const { app, watcher } = createStudioServer({ projectDir: dir, projectName: pName, autoProxy: options?.autoProxy, browserGpuMode: options?.browserGpuMode, }); - const serverBuildSignature = await loadPreviewServerBuildSignature(); - let result: FindPortResult; try { result = await findPortAndServe( @@ -1298,38 +1584,65 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - s.stop(c.error("Failed to start studio")); - console.error(); - console.error(` ${(err as Error).message}`); - console.error(); - setCommandExitCode(1); + watcher.close(); + reportPreviewFailure( + Boolean(options?.json), + "start", + "preview-start-failed", + (err as Error).message, + ); return; } if (result.type === "already-running") { + // createStudioServer acquires an fs.watch handle before port discovery. + // Reuse owns no local server, so release that handle before returning or + // the otherwise-finished CLI process remains alive indefinitely. + watcher.close(); const url = `http://localhost:${result.port}`; - s.stop(c.success("Already running")); - printStudioSummary(pName, url, { - details: ["Reusing existing server. Use --force-new to start a fresh instance."], - }); + if (options?.json) { + const server = await activeServerOnPort(result.port); + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: "reused", + mode: "foreground", + projectName: pName, + projectDir: dir, + port: result.port, + pid: publicPreviewPid(server?.pid, null), + }), + ), + ); + } else { + s.stop(c.success("Already running")); + printStudioSummary(pName, url, dir, { + details: ["Reusing existing server. Use --force-new to start a fresh instance."], + }); + } openStudioBrowser(url, pName, dir, options); return; } const url = `http://localhost:${result.port}`; - s.stop(c.success("Studio running")); - console.log(); - if (result.port !== startPort) { - console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + if (options?.json) { + writeLifecycleJson(foregroundPreviewReadyPayload(pName, url, dir, process.pid)); + } else { + s.stop(c.success("Studio running")); console.log(); + if (result.port !== startPort) { + console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + console.log(); + } + printStudioSummary(pName, url, dir, { + details: [ + "Edit with your AI agent — it has HyperFrames skills installed.", + "Changes reload automatically in the studio.", + ], + footer: "Press Ctrl+C to stop", + }); } - printStudioSummary(pName, url, { - details: [ - "Edit with your AI agent — it has HyperFrames skills installed.", - "Changes reload automatically in the studio.", - ], - footer: "Press Ctrl+C to stop", - }); openStudioBrowser(url, pName, dir, options); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening @@ -1345,7 +1658,10 @@ async function runEmbeddedMode( let rl: import("node:readline").Interface | undefined; if (process.platform === "win32") { const readline = await import("node:readline"); - rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); rl.on("SIGINT", () => { process.emit("SIGINT", "SIGINT"); }); @@ -1359,8 +1675,7 @@ async function runEmbeddedMode( process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); rl?.close(); - console.log(); - console.log(` ${c.dim("Shutting down studio...")}`); + reportPreviewShutdown(Boolean(options?.json)); // Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to // browser.close()), force exit. Armed before awaiting cleanup so it @@ -1379,6 +1694,7 @@ async function runEmbeddedMode( cleanup() .catch(() => {}) .finally(() => { + watcher.close(); result.server.close(() => resolveRun()); }); }; diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 1238dd48d0..c9635677f5 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -49,7 +49,7 @@ describe("background preview lifecycle", () => { ); }); - it("does not let the detached child inherit launcher-only flags", () => { + it("forces the detached child foreground without inheriting launcher-only flags", () => { expect( buildBackgroundPreviewArgs([ "/opt/hyperframes/cli.js", @@ -57,8 +57,9 @@ describe("background preview lifecycle", () => { projectDir, "--background", "--open", + "--json", ]), - ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]); + ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--foreground", "--no-open"]); }); it("reuses an already-running server for the same project", async () => { diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 02c0eb6cfc..b892cf9c9d 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -248,10 +248,13 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] { (arg) => arg !== "--background" && !arg.startsWith("--background=") && + arg !== "--foreground" && + !arg.startsWith("--foreground=") && arg !== "--open" && - arg !== "--no-open", + arg !== "--no-open" && + arg !== "--json", ); - return [...filtered, "--no-open"]; + return [...filtered, "--foreground", "--no-open"]; } export async function readBackgroundPreviewStatus( diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. diff --git a/packages/cli/src/utils/studioProxyEnv.test.ts b/packages/cli/src/utils/studioProxyEnv.test.ts index 9b95a2d436..10b1340ecb 100644 --- a/packages/cli/src/utils/studioProxyEnv.test.ts +++ b/packages/cli/src/utils/studioProxyEnv.test.ts @@ -12,4 +12,22 @@ describe("studioProxyEnv", () => { HYPERFRAMES_AUTO_PROXY: "false", }); }); + + it("identifies a detached Vite preview to the lifecycle scanner", () => { + expect( + studioProxyEnv( + true, + { KEEP: "yes" }, + { + projectDir: "/tmp/video", + projectName: "video", + browserGpuMode: "software", + }, + ), + ).toMatchObject({ + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }); + }); }); diff --git a/packages/cli/src/utils/studioProxyEnv.ts b/packages/cli/src/utils/studioProxyEnv.ts index cd19e4060b..2974f814bc 100644 --- a/packages/cli/src/utils/studioProxyEnv.ts +++ b/packages/cli/src/utils/studioProxyEnv.ts @@ -1,9 +1,23 @@ export function studioProxyEnv( autoProxy: boolean, baseEnv: NodeJS.ProcessEnv = process.env, + preview?: { + projectDir: string; + projectName: string; + browserGpuMode?: "auto" | "hardware" | "software"; + }, ): NodeJS.ProcessEnv { return { ...baseEnv, HYPERFRAMES_AUTO_PROXY: autoProxy ? "true" : "false", + ...(preview + ? { + HYPERFRAMES_PREVIEW_PROJECT_DIR: preview.projectDir, + HYPERFRAMES_PREVIEW_PROJECT_NAME: preview.projectName, + ...(preview.browserGpuMode + ? { HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: preview.browserGpuMode } + : {}), + } + : {}), }; } diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 97830c5d65..da670ed85f 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { readNodeRequestBody } from "./vite.request-body.js"; import { watch } from "chokidar"; import { createViteAdapter } from "./vite.adapter"; +import { previewConfigPayload } from "./vite.preview-config"; async function loadRuntimeSourceForDev( server: import("vite").ViteDevServer, @@ -84,6 +85,14 @@ function devProjectApi(): Plugin { return _api; }; + server.middlewares.use((req, res, next) => { + if (req.url !== "/__hyperframes_config") return next(); + const payload = previewConfigPayload(process.env, process.pid, studioPkg.version); + if (!payload) return next(); + res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); + }); + // Runtime endpoint — prefer source build over dist artifact server.middlewares.use((req, res, next) => { if (req.url !== "/api/runtime.js") return next(); diff --git a/packages/studio/vite.preview-config.test.ts b/packages/studio/vite.preview-config.test.ts new file mode 100644 index 0000000000..865de6cbf1 --- /dev/null +++ b/packages/studio/vite.preview-config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { previewConfigPayload } from "./vite.preview-config"; + +describe("previewConfigPayload", () => { + it("identifies a detached Vite preview to the CLI lifecycle scanner", () => { + expect( + previewConfigPayload( + { + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }, + 4321, + "0.7.109", + ), + ).toEqual({ + isHyperframes: true, + pid: 4321, + projectName: "video", + projectDir: "/tmp/video", + serverBuildSignature: null, + browserGpuMode: "software", + version: "0.7.109", + }); + }); + + it("does not claim unrelated direct Vite sessions", () => { + expect(previewConfigPayload({})).toBeNull(); + }); +}); diff --git a/packages/studio/vite.preview-config.ts b/packages/studio/vite.preview-config.ts new file mode 100644 index 0000000000..3fe0c38d1d --- /dev/null +++ b/packages/studio/vite.preview-config.ts @@ -0,0 +1,22 @@ +export type PreviewConfigEnv = Record; + +export function previewConfigPayload( + env: PreviewConfigEnv, + pid = process.pid, + version = "dev", +): Record | null { + const projectDir = env.HYPERFRAMES_PREVIEW_PROJECT_DIR; + const projectName = env.HYPERFRAMES_PREVIEW_PROJECT_NAME; + if (!projectDir || !projectName) return null; + + const browserGpuMode = env.HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE; + return { + isHyperframes: true, + pid, + projectName, + projectDir, + serverBuildSignature: null, + ...(browserGpuMode ? { browserGpuMode } : {}), + version, + }; +} diff --git a/skills-manifest.json b/skills-manifest.json index 39217fc336..ebf735abab 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 138 }, "faceless-explainer": { - "hash": "1eb3772e62dd71bb", + "hash": "8d3795c85481b4c3", "files": 24 }, "figma": { @@ -30,11 +30,11 @@ "files": 6 }, "hyperframes-cli": { - "hash": "d124991b0a27f02d", + "hash": "3fa884269c43d7df", "files": 11 }, "hyperframes-core": { - "hash": "ec542db377d8b213", + "hash": "2471f4b5049fb489", "files": 20 }, "hyperframes-creative": { @@ -54,7 +54,7 @@ "files": 152 }, "motion-graphics": { - "hash": "1434e22bb0259bbb", + "hash": "69dc088b8e0d22fe", "files": 23 }, "music-to-video": { @@ -66,7 +66,7 @@ "files": 30 }, "product-launch-video": { - "hash": "d562efe00647c14b", + "hash": "085243ed9167a0aa", "files": 28 }, "remotion-to-hyperframes": { @@ -74,11 +74,11 @@ "files": 70 }, "slideshow": { - "hash": "6a24a84b0c1a75f9", + "hash": "2029471821f6f371", "files": 2 }, "talking-head-recut": { - "hash": "2f5d99f823c48e75", + "hash": "214eda4c0f2bedb1", "files": 28 } } diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 3a519b3f48..3ba1891a9c 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -191,7 +191,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 3636ccc13c..09c4fe1a58 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -21,7 +21,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap 4. **Get fast feedback while editing:** run `npx hyperframes lint` after the first HTML pass and after structural changes. 5. **Run the final gate:** run `npx hyperframes check`; it reruns lint before opening the browser. Do not prepend a redundant standalone lint invocation. Add `--snapshots` for annotated overview frames and finding crops. 6. **Inspect sub-compositions:** when `index.html` mounts `data-composition-src`, capture midpoint snapshots and inspect each mounted scene. -7. **Open the final Studio preview:** run `npx hyperframes preview`, hand the timeline project URL to the user, and ask whether to revise or render. +7. **Open the final Studio preview:** run `npx hyperframes preview --background`, verify the URL returns HTTP 200, hand the timeline project URL to the user, and ask whether to revise or render. Keep it alive until review ends. 8. **Render only after approval:** use draft quality for iteration and high quality for delivery. 9. **Verify the output:** confirm the file exists, is non-empty, and has a plausible duration. @@ -45,7 +45,7 @@ npx hyperframes lint # Required final gate; includes lint. npx hyperframes check -npx hyperframes preview +npx hyperframes preview --background npx hyperframes render --quality high --output out.mp4 test -s out.mp4 ffprobe -v error -show_format out.mp4 diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index f5ed1f4e8e..db0e351ee2 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -5,8 +5,10 @@ Serve, render, and share commands. ## preview ```bash -npx hyperframes preview # serve current directory -npx hyperframes preview --port 4567 # custom port (default 3002) +npx hyperframes preview # foreground on a TTY; persistent in agent shells +npx hyperframes preview --background # explicit persistent session +npx hyperframes preview --foreground --json # ready JSON, then remain attached +npx hyperframes preview --background --port 4567 # agent-safe custom port (default 3002) npx hyperframes preview --selection --json # print the current Studio selection and exit npx hyperframes preview --context --json # print compact agent context from Studio ``` @@ -19,11 +21,11 @@ When handing a project back to the user, use the Studio project URL, not the sou http://localhost:/#project/ ``` -Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. +Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --background --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. To land the user on the **Storyboard view** instead of the timeline, put `?view=storyboard` ahead of the hash: `http://localhost:/?view=storyboard#project/`. Hand this URL whenever the storyboard is the thing to review and nothing is assembled yet — before `index.html` exists, the timeline stage has nothing to show, so the bare project URL opens on an empty player. -Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. `preview` is a long-running process — start it from the project directory as a background task, and if that task reports it exited ("completed"), the server is down: restart it, don't hand out the link. +Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. Bare `preview` automatically creates a managed persistent session in a non-TTY agent shell; `--background` remains the clearest explicit form. Verify the printed URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. Use the printed URL as-is: HyperFrames URL-encodes project names that contain route metacharacters. ### Agent context from Studio selection @@ -57,7 +59,7 @@ Failure modes: | Code | Meaning | | -------------------------- | -------------------------------------------------------------------------- | -| `preview-not-running` | Start Studio first with `npx hyperframes preview`. | +| `preview-not-running` | Start Studio first with `npx hyperframes preview --background`. | | `ambiguous-preview-server` | Multiple matching Studio servers are open; rerun with one listed `--port`. | | `preview-port-mismatch` | The requested `--port` is not one of the matching Studio servers. | | `no-selection` | Studio is open, but the user has not selected an element yet. | @@ -89,7 +91,7 @@ Both `preview` and `play` can open inside an explicit Chromium-compatible browse ```bash # Open preview in an isolated Chromium profile -npx hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile +npx hyperframes preview --background --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile # Same plus a CDP endpoint on :9222 (attach DevTools / Playwright / etc.) npx hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222 diff --git a/skills/hyperframes-core/SKILL.md b/skills/hyperframes-core/SKILL.md index d66272fde1..80b18ceef5 100644 --- a/skills/hyperframes-core/SKILL.md +++ b/skills/hyperframes-core/SKILL.md @@ -86,5 +86,5 @@ Use `hyperframes-cli` for command details - [ ] `npx hyperframes check` passes (0 findings across lint, runtime, layout, motion, and contrast) - [ ] Projects with sub-compositions: `npx hyperframes snapshot --at ` and eyeball each frame -- [ ] `npx hyperframes preview` for review (the user can edit anything in Studio's timeline) +- [ ] `npx hyperframes preview --background` for review (the user can edit anything in Studio's timeline, and the server survives the invoking command) - [ ] `npx hyperframes render` only after the user approves diff --git a/skills/hyperframes-core/references/review-loop.md b/skills/hyperframes-core/references/review-loop.md index 7ae9d5e6df..8a9ecc3d67 100644 --- a/skills/hyperframes-core/references/review-loop.md +++ b/skills/hyperframes-core/references/review-loop.md @@ -6,7 +6,7 @@ This is the shared process for any workflow that plans on a storyboard. The cont ## § 1 — The plan, on a live board -Open the **storyboard board** before presenting the plan: run `npx hyperframes preview` from the project directory in the background, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. +Open the **storyboard board** before presenting the plan: run `npx hyperframes preview --background` from the project directory, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. Present the plan as a proposal (shape: `hyperframes-creative/references/story-spine.md` § 3): open by echoing **"This video tells [audience] that [message]"**, then the frame table — one row per frame: frame · beat (type, duration) · on screen · why (its `narrativeRole`, traced to the message). Hand the board URL with it, noting feedback lands in both places — comment on the board or reply here, one revision loop — and that a board submit still needs one reply here (anything) to get picked up. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 90bf727088..e0302dbee9 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -136,7 +136,7 @@ Choose proof times that show the opening state, signature move, and final hold. Ask one question: “preview first, or render?” If the user chooses preview, open Studio and return to the same approval gate after revisions: ```bash -(cd "$PROJECT_DIR" && npx hyperframes preview) +(cd "$PROJECT_DIR" && npx hyperframes preview --background) ``` Render only after an explicit render answer: diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 1fd786de99..464604cfdd 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -220,7 +220,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md index fffa3f99b2..57df159ff6 100644 --- a/skills/slideshow/SKILL.md +++ b/skills/slideshow/SKILL.md @@ -494,7 +494,7 @@ Studio/`preview` is useful for editing a composition, but it is not a clear fina { "scripts": { "dev": "npx hyperframes present ./composition", - "studio": "npx hyperframes preview ./composition" + "studio": "npx hyperframes preview ./composition --background" } } ``` diff --git a/skills/talking-head-recut/SKILL.md b/skills/talking-head-recut/SKILL.md index a9c797ff52..275c7bf129 100644 --- a/skills/talking-head-recut/SKILL.md +++ b/skills/talking-head-recut/SKILL.md @@ -1204,7 +1204,7 @@ Tell the user: **Optional live preview (on request only).** The clip plays unchanged inside `public/index.html` with the overlays on top, so it previews faithfully. **Don't open it during the run.** When the user asks, start a long-lived server **after** render and report the URL: ```bash -(cd "$WORK_DIR/public" && npx hyperframes preview) # or `npx hyperframes play` for a shareable link +(cd "$WORK_DIR/public" && npx hyperframes preview --background) # or `npx hyperframes play` for a shareable link ``` Do not delete the work directory unless the user asks.