mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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>(() => {});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user