From 1e1d7b94ae6f315eb5fb220ae2d99aa3026bc668 Mon Sep 17 00:00:00 2001 From: AnoKno <122017492+AnoKno@users.noreply.github.com> Date: Sat, 16 May 2026 11:42:06 +0900 Subject: [PATCH 1/2] feat(cli): add browser launch options to preview and play --- packages/cli/src/commands/play.ts | 22 +++++++- packages/cli/src/commands/preview.ts | 63 ++++++++++++++++++---- packages/cli/src/utils/openBrowser.test.ts | 40 ++++++++++++++ packages/cli/src/utils/openBrowser.ts | 37 +++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/utils/openBrowser.test.ts create mode 100644 packages/cli/src/utils/openBrowser.ts diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index a09c6ca8c..2be0552fb 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -7,11 +7,13 @@ export const examples: Example[] = [ ["Play a specific project directory", "hyperframes play ./my-video"], ["Use a custom port", "hyperframes play --port 8080"], ["Start without opening the browser", "hyperframes play --no-open"], + ["Open with a specific browser", "hyperframes play --browser-path /usr/bin/chromium"], ]; import { resolve, dirname } from "node:path"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; +import { openBrowser } from "../utils/openBrowser.js"; export default defineCommand({ meta: { name: "play", description: "Play a composition in a lightweight browser player" }, @@ -23,11 +25,26 @@ export default defineCommand({ default: true, description: "Open browser automatically", }, + "browser-path": { + type: "string", + description: "Path to the browser executable to open", + }, + "user-data-dir": { + type: "string", + description: "Chromium-compatible user data directory (requires --browser-path)", + }, }, async run({ args }) { const project = resolveProject(args.dir); const startPort = parseInt(args.port ?? "3003", 10); + // Validation: --user-data-dir requires --browser-path + if (args["user-data-dir"] && !args["browser-path"]) { + clack.log.error("--user-data-dir requires --browser-path"); + process.exitCode = 1; + return; + } + // Resolve runtime path — same logic as studioServer.ts const runtimePath = resolveRuntimePath(); if (!runtimePath) { @@ -152,7 +169,10 @@ export default defineCommand({ console.log(` ${c.dim("Press Ctrl+C to stop")}`); console.log(); if (args.open) { - import("open").then((mod) => mod.default(url)).catch(() => {}); + void openBrowser(url, { + browserPath: args["browser-path"] as string | undefined, + userDataDir: args["user-data-dir"] as string | undefined, + }); } return new Promise(() => {}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index df35ede8e..4ec46685d 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -8,6 +8,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"], ["Start without opening the browser", "hyperframes preview --no-open"], + ["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"], ["List all active preview servers", "hyperframes preview --list"], ["Kill all active preview servers", "hyperframes preview --kill-all"], ]; @@ -18,6 +19,7 @@ import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; +import { openBrowser } from "../utils/openBrowser.js"; import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { @@ -52,6 +54,14 @@ export default defineCommand({ default: true, description: "Open browser automatically", }, + "browser-path": { + type: "string", + description: "Path to the browser executable to open", + }, + "user-data-dir": { + type: "string", + description: "Chromium-compatible user data directory (requires --browser-path)", + }, }, async run({ args }) { const startPort = parseInt(args.port ?? "3002", 10); @@ -106,19 +116,34 @@ 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"); + process.exitCode = 1; + return; + } + const noOpen = !args.open; + const browserPath = args["browser-path"] as string | undefined; + const userDataDir = args["user-data-dir"] as string | undefined; if (isDevMode()) { - return runDevMode(dir, { projectName, noOpen }); + return runDevMode(dir, { projectName, noOpen, browserPath, userDataDir }); } // If @hyperframes/studio is installed locally, use Vite for full HMR if (hasLocalStudio(dir)) { - return runLocalStudioMode(dir, { projectName, noOpen }); + return runLocalStudioMode(dir, { projectName, noOpen, browserPath, userDataDir }); } const forceNew = !!args["force-new"]; - return runEmbeddedMode(dir, startPort, { projectName, forceNew, noOpen }); + return runEmbeddedMode(dir, startPort, { + projectName, + forceNew, + noOpen, + browserPath, + userDataDir, + }); }, }); @@ -127,7 +152,7 @@ export default defineCommand({ */ async function runDevMode( dir: string, - options?: { projectName?: string; noOpen?: boolean }, + options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string }, ): Promise { // Find monorepo root by navigating from packages/cli/src/commands/ const thisFile = fileURLToPath(import.meta.url); @@ -194,7 +219,10 @@ async function runDevMode( if (!options?.noOpen) { const urlToOpen = `${frontendUrl}#project/${pName}`; - import("open").then((mod) => mod.default(urlToOpen)).catch(() => {}); + openBrowser(urlToOpen, { + browserPath: options?.browserPath, + userDataDir: options?.userDataDir, + }); } child.stdout?.removeListener("data", handleOutput); @@ -247,7 +275,7 @@ function hasLocalStudio(dir: string): boolean { */ async function runLocalStudioMode( dir: string, - options?: { projectName?: string; noOpen?: boolean }, + options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string }, ): Promise { const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); @@ -296,7 +324,10 @@ async function runLocalStudioMode( console.log(` ${c.dim("Press Ctrl+C to stop")}`); console.log(); if (!options?.noOpen) { - import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {}); + openBrowser(`${url}#project/${pName}`, { + browserPath: options?.browserPath, + userDataDir: options?.userDataDir, + }); } } } @@ -333,7 +364,13 @@ async function runLocalStudioMode( async function runEmbeddedMode( dir: string, startPort: number, - options?: { projectName?: string; forceNew?: boolean; noOpen?: boolean }, + options?: { + projectName?: string; + forceNew?: boolean; + noOpen?: boolean; + browserPath?: string; + userDataDir?: string; + }, ): Promise { const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js"); @@ -384,7 +421,10 @@ async function runEmbeddedMode( ); console.log(); if (!options?.noOpen) { - import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {}); + openBrowser(`${url}#project/${pName}`, { + browserPath: options?.browserPath, + userDataDir: options?.userDataDir, + }); } return; } @@ -405,7 +445,10 @@ async function runEmbeddedMode( console.log(` ${c.dim("Press Ctrl+C to stop")}`); console.log(); if (!options?.noOpen) { - import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {}); + openBrowser(`${url}#project/${pName}`, { + browserPath: options?.browserPath, + userDataDir: options?.userDataDir, + }); } // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening diff --git a/packages/cli/src/utils/openBrowser.test.ts b/packages/cli/src/utils/openBrowser.test.ts new file mode 100644 index 000000000..96327d5af --- /dev/null +++ b/packages/cli/src/utils/openBrowser.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "bun:test"; +import { buildBrowserArgs } from "./openBrowser.js"; + +describe("buildBrowserArgs", () => { + it("returns only the URL when no options are given", () => { + expect(buildBrowserArgs("http://localhost:3002", {})).toEqual(["http://localhost:3002"]); + }); + + it("returns only the URL when only browserPath is set (args do not include it)", () => { + // browserPath is used by the caller to decide spawn vs open, not in args + expect(buildBrowserArgs("http://localhost:3002", { browserPath: "/usr/bin/chromium" })).toEqual( + ["http://localhost:3002"], + ); + }); + + it("prepends --user-data-dir before the URL", () => { + expect( + buildBrowserArgs("http://localhost:3002", { + userDataDir: "D:\\tmp\\profile", + }), + ).toEqual(["--user-data-dir=D:\\tmp\\profile", "http://localhost:3002"]); + }); + + it("prepends --user-data-dir with both options", () => { + expect( + buildBrowserArgs("http://localhost:3002", { + browserPath: "/usr/bin/chromium", + userDataDir: "/tmp/hf-profile", + }), + ).toEqual(["--user-data-dir=/tmp/hf-profile", "http://localhost:3002"]); + }); + + it("handles paths with spaces", () => { + expect( + buildBrowserArgs("http://localhost:3002", { + userDataDir: "C:\\Documents and Settings\\profile", + }), + ).toEqual(["--user-data-dir=C:\\Documents and Settings\\profile", "http://localhost:3002"]); + }); +}); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts new file mode 100644 index 000000000..9f2cb6921 --- /dev/null +++ b/packages/cli/src/utils/openBrowser.ts @@ -0,0 +1,37 @@ +import { spawn } from "node:child_process"; + +export interface OpenBrowserOptions { + browserPath?: string; + userDataDir?: string; +} + +/** + * Build the argument list for spawning a browser process. + * + * Pure function — easy to unit-test without mocking `spawn` or `import("open")`. + */ +export function buildBrowserArgs(url: string, options: OpenBrowserOptions): string[] { + const args: string[] = []; + if (options.userDataDir) { + args.push(`--user-data-dir=${options.userDataDir}`); + } + args.push(url); + return args; +} + +/** + * Open a URL in the browser with the given options. + * + * - browserPath: spawn the given binary directly (enables Chromium flags) + * - userDataDir: passed as --user-data-dir (requires browserPath) + * - otherwise: fall back to the `open` package (default browser) + */ +export function openBrowser(url: string, options: OpenBrowserOptions = {}): void { + if (options.browserPath) { + const args = buildBrowserArgs(url, options); + spawn(options.browserPath, args, { detached: true, stdio: "ignore" }).unref(); + return; + } + + import("open").then((mod) => mod.default(url)).catch(() => {}); +} From 5133613221a4cbdd5acebcb872e190c9ecb1625b Mon Sep 17 00:00:00 2001 From: AnoKno <122017492+AnoKno@users.noreply.github.com> Date: Sat, 16 May 2026 11:47:18 +0900 Subject: [PATCH 2/2] fix(cli): add spawn error listener to prevent ENOENT crash --- packages/cli/src/utils/openBrowser.test.ts | 2 +- packages/cli/src/utils/openBrowser.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/openBrowser.test.ts b/packages/cli/src/utils/openBrowser.test.ts index 96327d5af..9d2a22338 100644 --- a/packages/cli/src/utils/openBrowser.test.ts +++ b/packages/cli/src/utils/openBrowser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test"; +import { describe, it, expect } from "vitest"; import { buildBrowserArgs } from "./openBrowser.js"; describe("buildBrowserArgs", () => { diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index 9f2cb6921..97d01ff5a 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -29,7 +29,12 @@ export function buildBrowserArgs(url: string, options: OpenBrowserOptions): stri export function openBrowser(url: string, options: OpenBrowserOptions = {}): void { if (options.browserPath) { const args = buildBrowserArgs(url, options); - spawn(options.browserPath, args, { detached: true, stdio: "ignore" }).unref(); + const child = spawn(options.browserPath, args, { + detached: true, + stdio: "ignore", + }); + child.on("error", () => {}); + child.unref(); return; }