Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/cli/src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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) {
Expand Down Expand Up @@ -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>(() => {});
Expand Down
63 changes: 53 additions & 10 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
];
Expand 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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
},
});

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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,
});
}
}
}
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/utils/openBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 42 additions & 0 deletions packages/cli/src/utils/openBrowser.ts
Original file line number Diff line number Diff line change
@@ -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(() => {});
}
Loading