feat(cli): add browser launch options to preview and play (#884)

* feat(cli): add browser launch options to preview and play

* fix(cli): add spawn error listener to prevent ENOENT crash

---------

Co-authored-by: AnoKno <122017492+AnoKno@users.noreply.github.com>
This commit is contained in:
na-navi
2026-05-16 21:05:00 +02:00
committed by GitHub
co-authored by AnoKno
parent 883260aae3
commit 64b3ae755d
4 changed files with 156 additions and 11 deletions
+21 -1
View File
@@ -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<void>(() => {});
+53 -10
View File
@@ -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<void> {
// 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<void> {
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<void> {
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
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
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"]);
});
});
+42
View File
@@ -0,0 +1,42 @@
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);
const child = spawn(options.browserPath, args, {
detached: true,
stdio: "ignore",
});
child.on("error", () => {});
child.unref();
return;
}
import("open").then((mod) => mod.default(url)).catch(() => {});
}