Skip to content

Commit 64b3ae7

Browse files
na-naviAnoKno
andauthored
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>
1 parent 883260a commit 64b3ae7

4 files changed

Lines changed: 156 additions & 11 deletions

File tree

packages/cli/src/commands/play.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ export const examples: Example[] = [
77
["Play a specific project directory", "hyperframes play ./my-video"],
88
["Use a custom port", "hyperframes play --port 8080"],
99
["Start without opening the browser", "hyperframes play --no-open"],
10+
["Open with a specific browser", "hyperframes play --browser-path /usr/bin/chromium"],
1011
];
1112
import { resolve, dirname } from "node:path";
1213
import * as clack from "@clack/prompts";
1314
import { c } from "../ui/colors.js";
1415
import { resolveProject } from "../utils/project.js";
16+
import { openBrowser } from "../utils/openBrowser.js";
1517

1618
export default defineCommand({
1719
meta: { name: "play", description: "Play a composition in a lightweight browser player" },
@@ -23,11 +25,26 @@ export default defineCommand({
2325
default: true,
2426
description: "Open browser automatically",
2527
},
28+
"browser-path": {
29+
type: "string",
30+
description: "Path to the browser executable to open",
31+
},
32+
"user-data-dir": {
33+
type: "string",
34+
description: "Chromium-compatible user data directory (requires --browser-path)",
35+
},
2636
},
2737
async run({ args }) {
2838
const project = resolveProject(args.dir);
2939
const startPort = parseInt(args.port ?? "3003", 10);
3040

41+
// Validation: --user-data-dir requires --browser-path
42+
if (args["user-data-dir"] && !args["browser-path"]) {
43+
clack.log.error("--user-data-dir requires --browser-path");
44+
process.exitCode = 1;
45+
return;
46+
}
47+
3148
// Resolve runtime path — same logic as studioServer.ts
3249
const runtimePath = resolveRuntimePath();
3350
if (!runtimePath) {
@@ -152,7 +169,10 @@ export default defineCommand({
152169
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
153170
console.log();
154171
if (args.open) {
155-
import("open").then((mod) => mod.default(url)).catch(() => {});
172+
void openBrowser(url, {
173+
browserPath: args["browser-path"] as string | undefined,
174+
userDataDir: args["user-data-dir"] as string | undefined,
175+
});
156176
}
157177

158178
return new Promise<void>(() => {});

packages/cli/src/commands/preview.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const examples: Example[] = [
88
["Use a custom port", "hyperframes preview --port 8080"],
99
["Force a new server even if one is already running", "hyperframes preview --force-new"],
1010
["Start without opening the browser", "hyperframes preview --no-open"],
11+
["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"],
1112
["List all active preview servers", "hyperframes preview --list"],
1213
["Kill all active preview servers", "hyperframes preview --kill-all"],
1314
];
@@ -18,6 +19,7 @@ import { createRequire } from "node:module";
1819
import * as clack from "@clack/prompts";
1920
import { c } from "../ui/colors.js";
2021
import { isDevMode } from "../utils/env.js";
22+
import { openBrowser } from "../utils/openBrowser.js";
2123
import { lintProject } from "../utils/lintProject.js";
2224
import { formatLintFindings } from "../utils/lintFormat.js";
2325
import {
@@ -52,6 +54,14 @@ export default defineCommand({
5254
default: true,
5355
description: "Open browser automatically",
5456
},
57+
"browser-path": {
58+
type: "string",
59+
description: "Path to the browser executable to open",
60+
},
61+
"user-data-dir": {
62+
type: "string",
63+
description: "Chromium-compatible user data directory (requires --browser-path)",
64+
},
5565
},
5666
async run({ args }) {
5767
const startPort = parseInt(args.port ?? "3002", 10);
@@ -106,19 +116,34 @@ export default defineCommand({
106116
}
107117
}
108118

119+
// Validation: --user-data-dir requires --browser-path
120+
if (args["user-data-dir"] && !args["browser-path"]) {
121+
clack.log.error("--user-data-dir requires --browser-path");
122+
process.exitCode = 1;
123+
return;
124+
}
125+
109126
const noOpen = !args.open;
127+
const browserPath = args["browser-path"] as string | undefined;
128+
const userDataDir = args["user-data-dir"] as string | undefined;
110129

111130
if (isDevMode()) {
112-
return runDevMode(dir, { projectName, noOpen });
131+
return runDevMode(dir, { projectName, noOpen, browserPath, userDataDir });
113132
}
114133

115134
// If @hyperframes/studio is installed locally, use Vite for full HMR
116135
if (hasLocalStudio(dir)) {
117-
return runLocalStudioMode(dir, { projectName, noOpen });
136+
return runLocalStudioMode(dir, { projectName, noOpen, browserPath, userDataDir });
118137
}
119138

120139
const forceNew = !!args["force-new"];
121-
return runEmbeddedMode(dir, startPort, { projectName, forceNew, noOpen });
140+
return runEmbeddedMode(dir, startPort, {
141+
projectName,
142+
forceNew,
143+
noOpen,
144+
browserPath,
145+
userDataDir,
146+
});
122147
},
123148
});
124149

@@ -127,7 +152,7 @@ export default defineCommand({
127152
*/
128153
async function runDevMode(
129154
dir: string,
130-
options?: { projectName?: string; noOpen?: boolean },
155+
options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string },
131156
): Promise<void> {
132157
// Find monorepo root by navigating from packages/cli/src/commands/
133158
const thisFile = fileURLToPath(import.meta.url);
@@ -194,7 +219,10 @@ async function runDevMode(
194219

195220
if (!options?.noOpen) {
196221
const urlToOpen = `${frontendUrl}#project/${pName}`;
197-
import("open").then((mod) => mod.default(urlToOpen)).catch(() => {});
222+
openBrowser(urlToOpen, {
223+
browserPath: options?.browserPath,
224+
userDataDir: options?.userDataDir,
225+
});
198226
}
199227

200228
child.stdout?.removeListener("data", handleOutput);
@@ -247,7 +275,7 @@ function hasLocalStudio(dir: string): boolean {
247275
*/
248276
async function runLocalStudioMode(
249277
dir: string,
250-
options?: { projectName?: string; noOpen?: boolean },
278+
options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string },
251279
): Promise<void> {
252280
const req = createRequire(join(dir, "package.json"));
253281
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
@@ -296,7 +324,10 @@ async function runLocalStudioMode(
296324
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
297325
console.log();
298326
if (!options?.noOpen) {
299-
import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {});
327+
openBrowser(`${url}#project/${pName}`, {
328+
browserPath: options?.browserPath,
329+
userDataDir: options?.userDataDir,
330+
});
300331
}
301332
}
302333
}
@@ -333,7 +364,13 @@ async function runLocalStudioMode(
333364
async function runEmbeddedMode(
334365
dir: string,
335366
startPort: number,
336-
options?: { projectName?: string; forceNew?: boolean; noOpen?: boolean },
367+
options?: {
368+
projectName?: string;
369+
forceNew?: boolean;
370+
noOpen?: boolean;
371+
browserPath?: string;
372+
userDataDir?: string;
373+
},
337374
): Promise<void> {
338375
const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js");
339376

@@ -384,7 +421,10 @@ async function runEmbeddedMode(
384421
);
385422
console.log();
386423
if (!options?.noOpen) {
387-
import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {});
424+
openBrowser(`${url}#project/${pName}`, {
425+
browserPath: options?.browserPath,
426+
userDataDir: options?.userDataDir,
427+
});
388428
}
389429
return;
390430
}
@@ -405,7 +445,10 @@ async function runEmbeddedMode(
405445
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
406446
console.log();
407447
if (!options?.noOpen) {
408-
import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {});
448+
openBrowser(`${url}#project/${pName}`, {
449+
browserPath: options?.browserPath,
450+
userDataDir: options?.userDataDir,
451+
});
409452
}
410453

411454
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from "vitest";
2+
import { buildBrowserArgs } from "./openBrowser.js";
3+
4+
describe("buildBrowserArgs", () => {
5+
it("returns only the URL when no options are given", () => {
6+
expect(buildBrowserArgs("http://localhost:3002", {})).toEqual(["http://localhost:3002"]);
7+
});
8+
9+
it("returns only the URL when only browserPath is set (args do not include it)", () => {
10+
// browserPath is used by the caller to decide spawn vs open, not in args
11+
expect(buildBrowserArgs("http://localhost:3002", { browserPath: "/usr/bin/chromium" })).toEqual(
12+
["http://localhost:3002"],
13+
);
14+
});
15+
16+
it("prepends --user-data-dir before the URL", () => {
17+
expect(
18+
buildBrowserArgs("http://localhost:3002", {
19+
userDataDir: "D:\\tmp\\profile",
20+
}),
21+
).toEqual(["--user-data-dir=D:\\tmp\\profile", "http://localhost:3002"]);
22+
});
23+
24+
it("prepends --user-data-dir with both options", () => {
25+
expect(
26+
buildBrowserArgs("http://localhost:3002", {
27+
browserPath: "/usr/bin/chromium",
28+
userDataDir: "/tmp/hf-profile",
29+
}),
30+
).toEqual(["--user-data-dir=/tmp/hf-profile", "http://localhost:3002"]);
31+
});
32+
33+
it("handles paths with spaces", () => {
34+
expect(
35+
buildBrowserArgs("http://localhost:3002", {
36+
userDataDir: "C:\\Documents and Settings\\profile",
37+
}),
38+
).toEqual(["--user-data-dir=C:\\Documents and Settings\\profile", "http://localhost:3002"]);
39+
});
40+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { spawn } from "node:child_process";
2+
3+
export interface OpenBrowserOptions {
4+
browserPath?: string;
5+
userDataDir?: string;
6+
}
7+
8+
/**
9+
* Build the argument list for spawning a browser process.
10+
*
11+
* Pure function — easy to unit-test without mocking `spawn` or `import("open")`.
12+
*/
13+
export function buildBrowserArgs(url: string, options: OpenBrowserOptions): string[] {
14+
const args: string[] = [];
15+
if (options.userDataDir) {
16+
args.push(`--user-data-dir=${options.userDataDir}`);
17+
}
18+
args.push(url);
19+
return args;
20+
}
21+
22+
/**
23+
* Open a URL in the browser with the given options.
24+
*
25+
* - browserPath: spawn the given binary directly (enables Chromium flags)
26+
* - userDataDir: passed as --user-data-dir (requires browserPath)
27+
* - otherwise: fall back to the `open` package (default browser)
28+
*/
29+
export function openBrowser(url: string, options: OpenBrowserOptions = {}): void {
30+
if (options.browserPath) {
31+
const args = buildBrowserArgs(url, options);
32+
const child = spawn(options.browserPath, args, {
33+
detached: true,
34+
stdio: "ignore",
35+
});
36+
child.on("error", () => {});
37+
child.unref();
38+
return;
39+
}
40+
41+
import("open").then((mod) => mod.default(url)).catch(() => {});
42+
}

0 commit comments

Comments
 (0)