From 3902a9a82bbcd7f3543738faf762381ec47e1f54 Mon Sep 17 00:00:00 2001 From: AnoKno <122017492+AnoKno@users.noreply.github.com> Date: Sat, 16 May 2026 12:01:10 +0900 Subject: [PATCH] feat(cli): add remote debugging port option Adds a Chromium remote debugging port flag for preview and play. The flag is only passed when launching an explicit browser/profile. HyperFrames still does not own CDP automation. --- packages/cli/src/commands/play.ts | 31 ++++++++- packages/cli/src/commands/preview.ts | 67 +++++++++++++++++-- packages/cli/src/utils/openBrowser.test.ts | 78 +++++++++++++++++++++- packages/cli/src/utils/openBrowser.ts | 28 ++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index 2be0552fb..b0398c189 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -13,7 +13,7 @@ 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 } from "../utils/openBrowser.js"; export default defineCommand({ meta: { name: "play", description: "Play a composition in a lightweight browser player" }, @@ -33,6 +33,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); @@ -44,6 +48,19 @@ export default defineCommand({ process.exitCode = 1; return; } + // Validation: --remote-debugging-port requires --browser-path and --user-data-dir + if (args["remote-debugging-port"]) { + if (!args["browser-path"]) { + clack.log.error("--remote-debugging-port requires --browser-path"); + process.exitCode = 1; + return; + } + if (!args["user-data-dir"]) { + clack.log.error("--remote-debugging-port requires --user-data-dir"); + process.exitCode = 1; + return; + } + } // Resolve runtime path — same logic as studioServer.ts const runtimePath = resolveRuntimePath(); @@ -168,10 +185,22 @@ export default defineCommand({ console.log(); console.log(` ${c.dim("Press Ctrl+C to stop")}`); console.log(); + let remoteDebuggingPort: number | undefined; + if (args["remote-debugging-port"]) { + try { + remoteDebuggingPort = parseRemoteDebuggingPort(args["remote-debugging-port"]); + } catch (err) { + clack.log.error((err as Error).message); + process.exitCode = 1; + return; + } + } + if (args.open) { void openBrowser(url, { browserPath: args["browser-path"] as string | undefined, userDataDir: args["user-data-dir"] as string | undefined, + remoteDebuggingPort, }); } diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 4caa46894..711e7acf2 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -19,7 +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 { openBrowser, parseRemoteDebuggingPort } from "../utils/openBrowser.js"; import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { @@ -63,6 +63,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); @@ -131,18 +135,53 @@ export default defineCommand({ process.exitCode = 1; return; } + // Validation: --remote-debugging-port requires --browser-path and --user-data-dir + if (args["remote-debugging-port"]) { + if (!args["browser-path"]) { + clack.log.error("--remote-debugging-port requires --browser-path"); + process.exitCode = 1; + return; + } + if (!args["user-data-dir"]) { + clack.log.error("--remote-debugging-port requires --user-data-dir"); + 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; + if (args["remote-debugging-port"]) { + try { + remoteDebuggingPort = parseRemoteDebuggingPort(args["remote-debugging-port"]); + } 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"]; @@ -152,6 +191,7 @@ export default defineCommand({ noOpen, browserPath, userDataDir, + remoteDebuggingPort, }); }, }); @@ -161,7 +201,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 { // Find monorepo root by navigating from packages/cli/src/commands/ const thisFile = fileURLToPath(import.meta.url); @@ -231,6 +277,7 @@ async function runDevMode( openBrowser(urlToOpen, { browserPath: options?.browserPath, userDataDir: options?.userDataDir, + remoteDebuggingPort: options?.remoteDebuggingPort, }); } @@ -294,7 +341,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 { const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); @@ -346,6 +399,7 @@ async function runLocalStudioMode( openBrowser(`${url}#project/${pName}`, { browserPath: options?.browserPath, userDataDir: options?.userDataDir, + remoteDebuggingPort: options?.remoteDebuggingPort, }); } } @@ -396,6 +450,7 @@ async function runEmbeddedMode( noOpen?: boolean; browserPath?: string; userDataDir?: string; + remoteDebuggingPort?: number; }, ): Promise { const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } = @@ -458,6 +513,7 @@ async function runEmbeddedMode( openBrowser(`${url}#project/${pName}`, { browserPath: options?.browserPath, userDataDir: options?.userDataDir, + remoteDebuggingPort: options?.remoteDebuggingPort, }); } return; @@ -482,6 +538,7 @@ async function runEmbeddedMode( openBrowser(`${url}#project/${pName}`, { browserPath: options?.browserPath, userDataDir: options?.userDataDir, + remoteDebuggingPort: options?.remoteDebuggingPort, }); } diff --git a/packages/cli/src/utils/openBrowser.test.ts b/packages/cli/src/utils/openBrowser.test.ts index 9d2a22338..e034c2171 100644 --- a/packages/cli/src/utils/openBrowser.test.ts +++ b/packages/cli/src/utils/openBrowser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildBrowserArgs } from "./openBrowser.js"; +import { buildBrowserArgs, parseRemoteDebuggingPort } from "./openBrowser.js"; describe("buildBrowserArgs", () => { it("returns only the URL when no options are given", () => { @@ -37,4 +37,80 @@ describe("buildBrowserArgs", () => { }), ).toEqual(["--user-data-dir=C:\\Documents and Settings\\profile", "http://localhost:3002"]); }); + + it("prepends --remote-debugging-port before the URL", () => { + 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", + ]); + }); + + 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(); + }); }); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index 97d01ff5a..d09d2bd42 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -3,6 +3,30 @@ import { spawn } from "node:child_process"; export interface OpenBrowserOptions { browserPath?: string; userDataDir?: string; + remoteDebuggingPort?: number; +} + +/** + * Validate and parse a --remote-debugging-port value. + * Returns the port number or undefined if not provided. + * Throws if the value is not a valid integer in 1..65535. + */ +export function parseRemoteDebuggingPort(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + + const text = String(value); + + if (!/^\d+$/.test(text)) { + throw new Error("--remote-debugging-port must be an integer between 1 and 65535"); + } + + const port = Number(text); + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("--remote-debugging-port must be an integer between 1 and 65535"); + } + + return port; } /** @@ -15,6 +39,9 @@ export function buildBrowserArgs(url: string, options: OpenBrowserOptions): stri if (options.userDataDir) { args.push(`--user-data-dir=${options.userDataDir}`); } + if (options.remoteDebuggingPort !== undefined) { + args.push(`--remote-debugging-port=${options.remoteDebuggingPort}`); + } args.push(url); return args; } @@ -24,6 +51,7 @@ export function buildBrowserArgs(url: string, options: OpenBrowserOptions): stri * * - browserPath: spawn the given binary directly (enables Chromium flags) * - userDataDir: passed as --user-data-dir (requires browserPath) + * - remoteDebuggingPort: passed as --remote-debugging-port (requires browserPath + userDataDir) * - otherwise: fall back to the `open` package (default browser) */ export function openBrowser(url: string, options: OpenBrowserOptions = {}): void {