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.