Skip to content
Open
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
39 changes: 38 additions & 1 deletion packages/cli/src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ export const examples: Example[] = [
["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"],
[
"Open with CDP enabled (requires browser path + isolated profile)",
"hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222",
],
];
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";
import {
openBrowser,
parseRemoteDebuggingPort,
validateRemoteDebuggingPortDeps,
} from "../utils/openBrowser.js";

export default defineCommand({
meta: { name: "play", description: "Play a composition in a lightweight browser player" },
Expand All @@ -33,6 +41,10 @@ export default defineCommand({
type: "string",
description: "Chromium-compatible user data directory (requires --browser-path)",
},
"remote-debugging-port": {
type: "string",
description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)",
},
},
async run({ args }) {
const project = resolveProject(args.dir);
Expand All @@ -44,6 +56,29 @@ export default defineCommand({
process.exitCode = 1;
return;
}
// Validation: --remote-debugging-port deps
const depsError = validateRemoteDebuggingPortDeps({
browserPath: args["browser-path"] as string | undefined,
userDataDir: args["user-data-dir"] as string | undefined,
remoteDebuggingPort: args["remote-debugging-port"] as string | undefined,
});
if (depsError) {
clack.log.error(depsError);
process.exitCode = 1;
return;
}
// Parse --remote-debugging-port before any server setup so an invalid value
// exits cleanly instead of leaving an orphan listening socket behind.
let remoteDebuggingPort: number | undefined;
try {
remoteDebuggingPort = parseRemoteDebuggingPort(
args["remote-debugging-port"] as string | undefined,
);
} catch (err) {
clack.log.error((err as Error).message);
process.exitCode = 1;
return;
}

// Resolve runtime path — same logic as studioServer.ts
const runtimePath = resolveRuntimePath();
Expand Down Expand Up @@ -168,10 +203,12 @@ export default defineCommand({
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();

if (args.open) {
void openBrowser(url, {
browserPath: args["browser-path"] as string | undefined,
userDataDir: args["user-data-dir"] as string | undefined,
remoteDebuggingPort,
});
}

Expand Down
73 changes: 68 additions & 5 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const examples: Example[] = [
["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"],
[
"Open with CDP enabled (requires browser path + isolated profile)",
"hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222",
],
["List all active preview servers", "hyperframes preview --list"],
["Kill all active preview servers", "hyperframes preview --kill-all"],
];
Expand All @@ -19,7 +23,11 @@ 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 {
openBrowser,
parseRemoteDebuggingPort,
validateRemoteDebuggingPortDeps,
} from "../utils/openBrowser.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import {
Expand Down Expand Up @@ -62,6 +70,10 @@ export default defineCommand({
type: "string",
description: "Chromium-compatible user data directory (requires --browser-path)",
},
"remote-debugging-port": {
type: "string",
description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)",
},
},
async run({ args }) {
const startPort = parseInt(args.port ?? "3002", 10);
Expand Down Expand Up @@ -122,18 +134,51 @@ export default defineCommand({
process.exitCode = 1;
return;
}
// Validation: --remote-debugging-port deps
const depsError = validateRemoteDebuggingPortDeps({
browserPath: args["browser-path"] as string | undefined,
userDataDir: args["user-data-dir"] as string | undefined,
remoteDebuggingPort: args["remote-debugging-port"] as string | undefined,
});
if (depsError) {
clack.log.error(depsError);
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;
let remoteDebuggingPort: number | undefined;
try {
remoteDebuggingPort = parseRemoteDebuggingPort(
args["remote-debugging-port"] as string | undefined,
);
} catch (err) {
clack.log.error((err as Error).message);
process.exitCode = 1;
return;
}

if (isDevMode()) {
return runDevMode(dir, { projectName, noOpen, browserPath, userDataDir });
return runDevMode(dir, {
projectName,
noOpen,
browserPath,
userDataDir,
remoteDebuggingPort,
});
}

// If @hyperframes/studio is installed locally, use Vite for full HMR
if (hasLocalStudio(dir)) {
return runLocalStudioMode(dir, { projectName, noOpen, browserPath, userDataDir });
return runLocalStudioMode(dir, {
projectName,
noOpen,
browserPath,
userDataDir,
remoteDebuggingPort,
});
}

const forceNew = !!args["force-new"];
Expand All @@ -143,6 +188,7 @@ export default defineCommand({
noOpen,
browserPath,
userDataDir,
remoteDebuggingPort,
});
},
});
Expand All @@ -152,7 +198,13 @@ export default defineCommand({
*/
async function runDevMode(
dir: string,
options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string },
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -222,6 +274,7 @@ async function runDevMode(
openBrowser(urlToOpen, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}

Expand Down Expand Up @@ -275,7 +328,13 @@ function hasLocalStudio(dir: string): boolean {
*/
async function runLocalStudioMode(
dir: string,
options?: { projectName?: string; noOpen?: boolean; browserPath?: string; userDataDir?: string },
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
): Promise<void> {
const req = createRequire(join(dir, "package.json"));
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
Expand Down Expand Up @@ -327,6 +386,7 @@ async function runLocalStudioMode(
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
}
Expand Down Expand Up @@ -370,6 +430,7 @@ async function runEmbeddedMode(
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
): Promise<void> {
const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js");
Expand Down Expand Up @@ -424,6 +485,7 @@ async function runEmbeddedMode(
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
return;
Expand All @@ -448,6 +510,7 @@ async function runEmbeddedMode(
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}

Expand Down
122 changes: 121 additions & 1 deletion packages/cli/src/utils/openBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { buildBrowserArgs } from "./openBrowser.js";
import {
buildBrowserArgs,
parseRemoteDebuggingPort,
validateRemoteDebuggingPortDeps,
} from "./openBrowser.js";

describe("buildBrowserArgs", () => {
it("returns only the URL when no options are given", () => {
Expand Down Expand Up @@ -37,4 +41,120 @@ describe("buildBrowserArgs", () => {
}),
).toEqual(["--user-data-dir=C:\\Documents and Settings\\profile", "http://localhost:3002"]);
});

it("omits --remote-debugging-port when userDataDir is missing (defense in depth)", () => {
// The CLI validation layer rejects this combination upstream, but
// buildBrowserArgs must not leak a CDP endpoint into the user's main
// profile even if a caller bypasses that check.
expect(
buildBrowserArgs("http://localhost:3002", {
browserPath: "/usr/bin/chromium",
remoteDebuggingPort: 9222,
}),
).toEqual(["http://localhost:3002"]);
});

it("includes all flags together", () => {
expect(
buildBrowserArgs("http://localhost:3002", {
browserPath: "/usr/bin/chromium",
userDataDir: "/tmp/hf-profile",
remoteDebuggingPort: 9222,
}),
).toEqual([
"--user-data-dir=/tmp/hf-profile",
"--remote-debugging-port=9222",
"http://localhost:3002",
]);
});
});

describe("parseRemoteDebuggingPort", () => {
it("returns undefined for undefined", () => {
expect(parseRemoteDebuggingPort(undefined)).toBeUndefined();
});

it("returns undefined for empty string", () => {
expect(parseRemoteDebuggingPort("")).toBeUndefined();
});

it("parses a valid port number", () => {
expect(parseRemoteDebuggingPort("9222")).toBe(9222);
});

it("parses port 1 (minimum)", () => {
expect(parseRemoteDebuggingPort("1")).toBe(1);
});

it("parses port 65535 (maximum)", () => {
expect(parseRemoteDebuggingPort("65535")).toBe(65535);
});

it("rejects 0", () => {
expect(() => parseRemoteDebuggingPort("0")).toThrow(
"--remote-debugging-port must be an integer between 1 and 65535",
);
});

it("rejects negative numbers", () => {
expect(() => parseRemoteDebuggingPort("-1")).toThrow();
});

it("rejects non-numeric input", () => {
expect(() => parseRemoteDebuggingPort("abc")).toThrow();
});

it("rejects trailing non-digits (no parseInt leakage)", () => {
expect(() => parseRemoteDebuggingPort("9222abc")).toThrow();
});

it("rejects numbers above 65535", () => {
expect(() => parseRemoteDebuggingPort("70000")).toThrow();
});

it("rejects decimals", () => {
expect(() => parseRemoteDebuggingPort("22.5")).toThrow();
});
});

describe("validateRemoteDebuggingPortDeps", () => {
it("returns null when --remote-debugging-port is not set", () => {
expect(validateRemoteDebuggingPortDeps({})).toBeNull();
});

it("returns null when all required flags are present", () => {
expect(
validateRemoteDebuggingPortDeps({
browserPath: "/usr/bin/chromium",
userDataDir: "/tmp/hf-profile",
remoteDebuggingPort: "9222",
}),
).toBeNull();
});

it("requires --browser-path when --remote-debugging-port is set", () => {
expect(
validateRemoteDebuggingPortDeps({
userDataDir: "/tmp/hf-profile",
remoteDebuggingPort: "9222",
}),
).toBe("--remote-debugging-port requires --browser-path");
});

it("requires --user-data-dir when --remote-debugging-port is set", () => {
expect(
validateRemoteDebuggingPortDeps({
browserPath: "/usr/bin/chromium",
remoteDebuggingPort: "9222",
}),
).toBe("--remote-debugging-port requires --user-data-dir");
});

it("reports --browser-path first when both deps are missing", () => {
expect(
validateRemoteDebuggingPortDeps({
remoteDebuggingPort: "9222",
}),
).toBe("--remote-debugging-port requires --browser-path");
});
});
Loading
Loading