chrome-cdp-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # chrome-cdp-manager
2
+
3
+ Set up and drive a **Chrome DevTools Protocol (CDP)** instance on macOS through a
4
+ dedicated `ChromeCDP.app` bundle, so launches always go through the same app and
5
+ the **Dock icon stays consistent**.
6
+
7
+ - Builds a real macOS `.app` bundle (`/Applications/ChromeCDP.app`) with a proper
8
+ `Info.plist` and Chrome's own icon — created automatically on first use.
9
+ - Launches Chrome headed via `open <bundle>` (so LaunchServices attaches the
10
+ ChromeCDP Dock icon) or headless via `--headless=new`.
11
+ - Connects over CDP with **zero heavy dependencies** — uses Node's built-in
12
+ `fetch` and `WebSocket` (no Playwright/Puppeteer, no browser download).
13
+
14
+ > Requires macOS on Apple Silicon, Node.js ≥ 22, and Google Chrome installed.
15
+
16
+ ## Usage
17
+
18
+ The package is published as `chrome-cdp-manager`; the command it installs is
19
+ **`chrome-cdp`**.
20
+
21
+ ```bash
22
+ # Run without installing — invoke via the package name
23
+ npx chrome-cdp-manager setup
24
+
25
+ # Or install once, then use the `chrome-cdp` command
26
+ npm install -g chrome-cdp-manager
27
+ ```
28
+
29
+ ```bash
30
+ # Create / repair ChromeCDP.app and save defaults (port 9222, profile ~/.chrome_cdp_profile)
31
+ chrome-cdp setup
32
+
33
+ # Launch ChromeCDP (Dock icon = ChromeCDP) and open a page
34
+ chrome-cdp open https://example.com
35
+
36
+ # Same, headless (no window/Dock)
37
+ chrome-cdp open https://example.com --headless
38
+
39
+ # Fetch a page's rendered HTML over CDP (headless by default)
40
+ chrome-cdp html example.com -o page.html
41
+
42
+ # Inspect state
43
+ chrome-cdp status
44
+
45
+ # Quit the running instance
46
+ chrome-cdp stop
47
+ ```
48
+
49
+ > Tip: with `npx`, run it by package name — `npx chrome-cdp-manager <command>` —
50
+ > since `npx chrome-cdp` would try to fetch an unrelated `chrome-cdp` package.
51
+
52
+ ## Commands
53
+
54
+ | Command | Description |
55
+ | ------------------ | --------------------------------------------------------------- |
56
+ | `setup` | Create or repair `ChromeCDP.app` (icon, `Info.plist`, launcher) |
57
+ | `open [url]` | Launch via the bundle, optionally open a URL; leaves it running |
58
+ | `html <url>` | Navigate and print/save the page's serialized HTML |
59
+ | `status` | Show bundle presence and CDP connection state |
60
+ | `stop` | Ask the running ChromeCDP instance to quit |
61
+
62
+ ## Common options
63
+
64
+ | Option | Description |
65
+ | ----------------------- | ------------------------------------------------------ |
66
+ | `--port <port>` | CDP port (default `9222`) |
67
+ | `-p, --profile <dir>` | Chrome `user-data-dir` (default `~/.chrome_cdp_profile`)|
68
+ | `-c, --chrome <path>` | Google Chrome executable |
69
+ | `--bundle <path>` | App bundle location (default `/Applications/ChromeCDP.app`) |
70
+ | `-t, --timeout <secs>` | Startup / load timeout (`open`, `html`) |
71
+
72
+ Port, profile, Chrome path and bundle path are baked into the app bundle and
73
+ persisted (`~/.config/chrome-cdp-manager/config.json`) at `setup` time so every
74
+ command agrees on the same environment. Re-run `setup --force` after changing
75
+ them.
76
+
77
+ ## How the Dock icon stays consistent
78
+
79
+ The bundle's executable is a small bash launcher:
80
+
81
+ ```bash
82
+ exec arch -arm64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
83
+ --remote-debugging-port=9222 \
84
+ --user-data-dir="$HOME/.chrome_cdp_profile" \
85
+ "$@"
86
+ ```
87
+
88
+ Because headed launches go through `open /Applications/ChromeCDP.app`,
89
+ LaunchServices runs Chrome under the ChromeCDP bundle identity — so you get the
90
+ ChromeCDP Dock icon every time instead of a generic/duplicated Chrome entry.
package/bin/cli.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.js";
3
+
4
+ run(process.argv).catch((error) => {
5
+ console.error(`\nError: ${error.message}`);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "chrome-cdp-manager",
3
+ "version": "1.0.0",
4
+ "description": "Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS through a dedicated ChromeCDP.app bundle, so the Dock icon stays consistent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "chrome-cdp": "bin/cli.js",
8
+ "chrome-cdp-manager": "bin/cli.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "os": [
20
+ "darwin"
21
+ ],
22
+ "scripts": {
23
+ "start": "node bin/cli.js",
24
+ "check": "node --check bin/cli.js && node --check src/cli.js",
25
+ "prepublishOnly": "npm run check",
26
+ "release": "bash scripts/publish.sh"
27
+ },
28
+ "keywords": [
29
+ "chrome",
30
+ "cdp",
31
+ "devtools-protocol",
32
+ "remote-debugging",
33
+ "headless",
34
+ "macos",
35
+ "automation"
36
+ ],
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "commander": "^12.1.0"
40
+ }
41
+ }
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ /**
6
+ * Build the bash launcher that the bundle executes. It bakes in the canonical
7
+ * Chrome path, CDP port and profile, and forwards any extra args (`"$@"`) so
8
+ * callers can append flags such as `--headless=new`.
9
+ */
10
+ function launcherScript({ chromePath, cdpPort, profileDir }) {
11
+ return `#!/usr/bin/env bash
12
+ # ChromeCDP launcher — generated by chrome-cdp-manager.
13
+ # Re-run \`npx chrome-cdp-manager setup\` to regenerate this file.
14
+ set -euo pipefail
15
+
16
+ CHROME=${shellQuote(chromePath)}
17
+ PORT=${shellQuote(String(cdpPort))}
18
+ PROFILE=${shellQuote(profileDir)}
19
+
20
+ exec arch -arm64 "$CHROME" \\
21
+ --remote-debugging-port="$PORT" \\
22
+ --user-data-dir="$PROFILE" \\
23
+ "$@"
24
+ `;
25
+ }
26
+
27
+ function infoPlist({ bundleId, version = "1.0.0" }) {
28
+ return `<?xml version="1.0" encoding="UTF-8"?>
29
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
30
+ <plist version="1.0">
31
+ <dict>
32
+ \t<key>CFBundleName</key>
33
+ \t<string>ChromeCDP</string>
34
+ \t<key>CFBundleDisplayName</key>
35
+ \t<string>ChromeCDP</string>
36
+ \t<key>CFBundleIdentifier</key>
37
+ \t<string>${bundleId}</string>
38
+ \t<key>CFBundleVersion</key>
39
+ \t<string>${version}</string>
40
+ \t<key>CFBundleShortVersionString</key>
41
+ \t<string>${version}</string>
42
+ \t<key>CFBundlePackageType</key>
43
+ \t<string>APPL</string>
44
+ \t<key>CFBundleExecutable</key>
45
+ \t<string>ChromeCDP</string>
46
+ \t<key>CFBundleIconFile</key>
47
+ \t<string>AppIcon</string>
48
+ \t<key>LSMinimumSystemVersion</key>
49
+ \t<string>11.0</string>
50
+ \t<key>NSHighResolutionCapable</key>
51
+ \t<true/>
52
+ </dict>
53
+ </plist>
54
+ `;
55
+ }
56
+
57
+ /** Quote a value for safe single-quoted embedding in bash. */
58
+ function shellQuote(value) {
59
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
60
+ }
61
+
62
+ /** Paths to the files that make up the bundle. */
63
+ export function bundleLayout(bundlePath) {
64
+ const contents = path.join(bundlePath, "Contents");
65
+ return {
66
+ contents,
67
+ macOS: path.join(contents, "MacOS"),
68
+ resources: path.join(contents, "Resources"),
69
+ executable: path.join(contents, "MacOS", "ChromeCDP"),
70
+ plist: path.join(contents, "Info.plist"),
71
+ icon: path.join(contents, "Resources", "AppIcon.icns"),
72
+ legacyIcon: path.join(bundlePath, "Icon\r"),
73
+ legacyIconNoCR: path.join(bundlePath, "Icon"),
74
+ };
75
+ }
76
+
77
+ /** True when the bundle's executable launcher already exists. */
78
+ export function bundleExists(bundlePath) {
79
+ return fs.existsSync(bundleLayout(bundlePath).executable);
80
+ }
81
+
82
+ /**
83
+ * Create or repair the ChromeCDP.app bundle so its Dock icon and launch
84
+ * behaviour are correct and consistent.
85
+ *
86
+ * @returns {{ created: boolean }} whether the bundle existed beforehand.
87
+ */
88
+ export function ensureAppBundle(config, { force = false } = {}) {
89
+ const { bundlePath, chromePath, chromeIcon, cdpPort, profileDir, bundleId } =
90
+ config;
91
+ const layout = bundleLayout(bundlePath);
92
+ const existed = bundleExists(bundlePath);
93
+
94
+ if (existed && !force) {
95
+ return { created: false };
96
+ }
97
+
98
+ if (!fs.existsSync(chromePath)) {
99
+ throw new Error(
100
+ `Google Chrome not found at:\n ${chromePath}\n` +
101
+ `Install Chrome or pass --chrome <path>.`,
102
+ );
103
+ }
104
+
105
+ try {
106
+ fs.mkdirSync(layout.macOS, { recursive: true });
107
+ fs.mkdirSync(layout.resources, { recursive: true });
108
+
109
+ fs.writeFileSync(
110
+ layout.executable,
111
+ launcherScript({ chromePath, cdpPort, profileDir }),
112
+ { mode: 0o755 },
113
+ );
114
+ fs.writeFileSync(layout.plist, infoPlist({ bundleId }));
115
+
116
+ // Use Chrome's own icon so the Dock entry is recognisable.
117
+ if (fs.existsSync(chromeIcon)) {
118
+ fs.copyFileSync(chromeIcon, layout.icon);
119
+ }
120
+
121
+ // Remove the legacy classic-Mac "Icon\r" resource-fork file if present;
122
+ // modern bundles render their icon from Info.plist + Resources/*.icns.
123
+ for (const stale of [layout.legacyIcon, layout.legacyIconNoCR]) {
124
+ try {
125
+ fs.rmSync(stale, { force: true });
126
+ } catch {
127
+ // best effort
128
+ }
129
+ }
130
+ } catch (error) {
131
+ if (error.code === "EACCES" || error.code === "EPERM") {
132
+ throw new Error(
133
+ `Permission denied writing to ${bundlePath}.\n` +
134
+ `Try: sudo npx chrome-cdp-manager setup`,
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ refreshLaunchServices(bundlePath);
141
+ return { created: !existed };
142
+ }
143
+
144
+ /**
145
+ * Register the bundle with LaunchServices and bump its mtime so Finder/Dock
146
+ * pick up the (possibly new) icon without a logout.
147
+ */
148
+ function refreshLaunchServices(bundlePath) {
149
+ const lsregister =
150
+ "/System/Library/Frameworks/CoreServices.framework/Frameworks/" +
151
+ "LaunchServices.framework/Support/lsregister";
152
+ try {
153
+ if (fs.existsSync(lsregister)) {
154
+ execFileSync(lsregister, ["-f", bundlePath], { stdio: "ignore" });
155
+ }
156
+ execFileSync("touch", [bundlePath]);
157
+ } catch {
158
+ // Non-fatal: the bundle still works, the icon cache may just lag.
159
+ }
160
+ }
package/src/cdp.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client built on Node's global `fetch` and
3
+ * `WebSocket` (Node >= 22). Avoids a heavyweight Playwright/Puppeteer
4
+ * dependency so `npx` stays fast and browser-download free.
5
+ */
6
+ export class CdpClient {
7
+ #ws;
8
+ #nextId = 0;
9
+ #pending = new Map();
10
+ #eventWaiters = new Map();
11
+
12
+ constructor(webSocketDebuggerUrl) {
13
+ this.url = webSocketDebuggerUrl;
14
+ }
15
+
16
+ /** Resolve the browser-level WebSocket endpoint and connect to it. */
17
+ static async connect(cdpPort) {
18
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
19
+ if (!res.ok) {
20
+ throw new Error(`CDP not reachable on port ${cdpPort} (HTTP ${res.status}).`);
21
+ }
22
+ const { webSocketDebuggerUrl } = await res.json();
23
+ if (!webSocketDebuggerUrl) {
24
+ throw new Error("CDP did not advertise a webSocketDebuggerUrl.");
25
+ }
26
+ const client = new CdpClient(webSocketDebuggerUrl);
27
+ await client.#open();
28
+ return client;
29
+ }
30
+
31
+ #open() {
32
+ return new Promise((resolve, reject) => {
33
+ this.#ws = new WebSocket(this.url);
34
+ this.#ws.addEventListener("open", () => resolve(), { once: true });
35
+ this.#ws.addEventListener(
36
+ "error",
37
+ () => reject(new Error("Failed to open CDP WebSocket.")),
38
+ { once: true },
39
+ );
40
+ this.#ws.addEventListener("message", (ev) => this.#onMessage(ev));
41
+ });
42
+ }
43
+
44
+ #onMessage(ev) {
45
+ let data;
46
+ try {
47
+ data = JSON.parse(ev.data);
48
+ } catch {
49
+ return;
50
+ }
51
+
52
+ if (data.id !== undefined && this.#pending.has(data.id)) {
53
+ const { resolve, reject } = this.#pending.get(data.id);
54
+ this.#pending.delete(data.id);
55
+ if (data.error) reject(new Error(data.error.message));
56
+ else resolve(data.result);
57
+ return;
58
+ }
59
+
60
+ if (data.method) {
61
+ const key = eventKey(data.method, data.sessionId);
62
+ const waiter = this.#eventWaiters.get(key);
63
+ if (waiter) {
64
+ this.#eventWaiters.delete(key);
65
+ waiter(data.params);
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Send a CDP command, optionally scoped to an attached session. */
71
+ send(method, params = {}, sessionId) {
72
+ const id = ++this.#nextId;
73
+ const message = { id, method, params };
74
+ if (sessionId) message.sessionId = sessionId;
75
+ this.#ws.send(JSON.stringify(message));
76
+ return new Promise((resolve, reject) => {
77
+ this.#pending.set(id, { resolve, reject });
78
+ });
79
+ }
80
+
81
+ /** Resolve the next time `method` fires (for the given session, if any). */
82
+ waitForEvent(method, sessionId) {
83
+ return new Promise((resolve) => {
84
+ this.#eventWaiters.set(eventKey(method, sessionId), resolve);
85
+ });
86
+ }
87
+
88
+ close() {
89
+ try {
90
+ this.#ws?.close();
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
95
+ }
96
+
97
+ function eventKey(method, sessionId) {
98
+ return sessionId ? `${sessionId}:${method}` : method;
99
+ }
100
+
101
+ /** Open a URL in a new tab and leave it running. Returns the new targetId. */
102
+ export async function openUrl(cdpPort, url) {
103
+ const cdp = await CdpClient.connect(cdpPort);
104
+ try {
105
+ const { targetId } = await cdp.send("Target.createTarget", { url });
106
+ return targetId;
107
+ } finally {
108
+ cdp.close();
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Navigate to a URL and return the page's serialized HTML.
114
+ * @param {object} opts
115
+ * @param {boolean} [opts.close] close the tab after capturing.
116
+ * @param {number} [opts.timeoutMs] navigation timeout.
117
+ */
118
+ export async function getPageHtml(cdpPort, url, { close = false, timeoutMs = 30_000 } = {}) {
119
+ const cdp = await CdpClient.connect(cdpPort);
120
+ let targetId;
121
+ try {
122
+ ({ targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }));
123
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
124
+ targetId,
125
+ flatten: true,
126
+ });
127
+
128
+ await cdp.send("Page.enable", {}, sessionId);
129
+ const loaded = cdp.waitForEvent("Page.loadEventFired", sessionId);
130
+ await cdp.send("Page.navigate", { url }, sessionId);
131
+
132
+ await Promise.race([
133
+ loaded,
134
+ delay(timeoutMs).then(() => {
135
+ throw new Error(`Timed out loading ${url} after ${timeoutMs}ms.`);
136
+ }),
137
+ ]);
138
+
139
+ const { result } = await cdp.send(
140
+ "Runtime.evaluate",
141
+ {
142
+ expression: "document.documentElement.outerHTML",
143
+ returnByValue: true,
144
+ },
145
+ sessionId,
146
+ );
147
+ return result.value;
148
+ } finally {
149
+ if (close && targetId) {
150
+ await cdp.send("Target.closeTarget", { targetId }).catch(() => {});
151
+ }
152
+ cdp.close();
153
+ }
154
+ }
155
+
156
+ /** Close the entire browser via CDP (graceful quit). */
157
+ export async function closeBrowser(cdpPort) {
158
+ const cdp = await CdpClient.connect(cdpPort);
159
+ try {
160
+ await cdp.send("Browser.close").catch(() => {});
161
+ } finally {
162
+ cdp.close();
163
+ }
164
+ }
165
+
166
+ function delay(ms) {
167
+ return new Promise((resolve) => setTimeout(resolve, ms));
168
+ }
package/src/chrome.js ADDED
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import { bundleLayout } from "./appBundle.js";
3
+
4
+ /** CDP HTTP endpoint helper. */
5
+ function versionUrl(cdpPort) {
6
+ return `http://127.0.0.1:${cdpPort}/json/version`;
7
+ }
8
+
9
+ /** Returns the CDP version payload if Chrome is already listening, else null. */
10
+ export async function probeCdp(cdpPort) {
11
+ try {
12
+ const res = await fetch(versionUrl(cdpPort), {
13
+ signal: AbortSignal.timeout(1000),
14
+ });
15
+ if (res.ok) return await res.json();
16
+ } catch {
17
+ // not up
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Launch Chrome through the ChromeCDP.app bundle.
24
+ *
25
+ * Headed mode goes through `open <bundle>` so LaunchServices attaches the
26
+ * ChromeCDP Dock icon. Headless mode runs the bundle's launcher directly
27
+ * (there is no Dock presence to keep consistent) and forwards `--headless=new`.
28
+ */
29
+ export function launchChrome({ bundlePath, headless }) {
30
+ if (headless) {
31
+ const { executable } = bundleLayout(bundlePath);
32
+ const child = spawn(executable, ["--headless=new"], {
33
+ detached: true,
34
+ stdio: "ignore",
35
+ });
36
+ child.unref();
37
+ return;
38
+ }
39
+
40
+ // `open` returns immediately; readiness is awaited via waitForCdp().
41
+ const child = spawn("open", [bundlePath], { stdio: "ignore" });
42
+ child.unref();
43
+ }
44
+
45
+ /** Poll the CDP endpoint until it responds or the timeout elapses. */
46
+ export async function waitForCdp(cdpPort, timeoutMs = 30_000) {
47
+ const deadline = Date.now() + timeoutMs;
48
+ while (Date.now() < deadline) {
49
+ const version = await probeCdp(cdpPort);
50
+ if (version) return version;
51
+ await delay(250);
52
+ }
53
+ throw new Error(
54
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for Chrome CDP ` +
55
+ `at ${versionUrl(cdpPort)}.`,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Ensure Chrome is running and CDP is reachable, launching it if needed.
61
+ * @returns {Promise<{ launched: boolean, version: object }>}
62
+ */
63
+ export async function ensureChromeRunning({ bundlePath, cdpPort, headless, timeoutMs }) {
64
+ const existing = await probeCdp(cdpPort);
65
+ if (existing) return { launched: false, version: existing };
66
+
67
+ launchChrome({ bundlePath, headless });
68
+ const version = await waitForCdp(cdpPort, timeoutMs);
69
+ return { launched: true, version };
70
+ }
71
+
72
+ function delay(ms) {
73
+ return new Promise((resolve) => setTimeout(resolve, ms));
74
+ }
package/src/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ import { createRequire } from "node:module";
2
+ import { Command } from "commander";
3
+
4
+ import { DEFAULTS } from "./config.js";
5
+ import {
6
+ setupCommand,
7
+ openCommand,
8
+ htmlCommand,
9
+ statusCommand,
10
+ stopCommand,
11
+ } from "./commands.js";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { version } = require("../package.json");
15
+
16
+ export async function run(argv) {
17
+ const program = new Command();
18
+
19
+ program
20
+ .name("chrome-cdp")
21
+ .description(
22
+ "Set up and drive a Chrome DevTools Protocol instance on macOS through a " +
23
+ "dedicated ChromeCDP.app bundle (consistent Dock icon).",
24
+ )
25
+ .version(version, "-v, --version");
26
+
27
+ // Options shared across commands.
28
+ const withCommon = (cmd) =>
29
+ cmd
30
+ .option("--port <port>", `CDP port (default: ${DEFAULTS.cdpPort})`)
31
+ .option("-p, --profile <dir>", "Chrome user-data-dir")
32
+ .option("-c, --chrome <path>", "Google Chrome executable path")
33
+ .option("--bundle <path>", `App bundle path (default: ${DEFAULTS.bundlePath})`);
34
+
35
+ withCommon(
36
+ program
37
+ .command("setup")
38
+ .description("Create or repair ChromeCDP.app (icon, Info.plist, launcher)")
39
+ .option("-f, --force", "Rewrite the bundle even if it already exists"),
40
+ ).action(setupCommand);
41
+
42
+ withCommon(
43
+ program
44
+ .command("open")
45
+ .argument("[url]", "URL to open after launch")
46
+ .description("Launch ChromeCDP via the app bundle and optionally open a URL")
47
+ .option("--headless", "Run headless (no window/Dock)", false)
48
+ .option("-t, --timeout <seconds>", "Startup timeout in seconds", "30"),
49
+ ).action(openCommand);
50
+
51
+ withCommon(
52
+ program
53
+ .command("html")
54
+ .argument("<url>", "URL to load")
55
+ .description("Print or save a page's HTML over CDP")
56
+ .option("--headless", "Run headless (default for html)")
57
+ .option("--headed", "Force a visible window")
58
+ .option("--close", "Close the tab when done", false)
59
+ .option("-o, --output <file>", "Write HTML to a file instead of stdout")
60
+ .option("-t, --timeout <seconds>", "Load timeout in seconds", "30"),
61
+ ).action((url, opts) => {
62
+ // --headed wins over --headless when both are passed.
63
+ const headless = opts.headed ? false : opts.headless;
64
+ return htmlCommand(url, { ...opts, headless });
65
+ });
66
+
67
+ withCommon(
68
+ program
69
+ .command("status")
70
+ .description("Show bundle and CDP connection status"),
71
+ ).action(statusCommand);
72
+
73
+ withCommon(
74
+ program.command("stop").description("Quit the running ChromeCDP instance"),
75
+ ).action(stopCommand);
76
+
77
+ await program.parseAsync(argv);
78
+ }
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { DEFAULTS, loadConfig, saveConfig, CONFIG_FILE } from "./config.js";
5
+ import { assertSupportedPlatform } from "./platform.js";
6
+ import { ensureAppBundle, bundleExists } from "./appBundle.js";
7
+ import { ensureChromeRunning, probeCdp } from "./chrome.js";
8
+ import { openUrl, getPageHtml, closeBrowser } from "./cdp.js";
9
+
10
+ /** Merge persisted config with CLI overrides into a fully-resolved config. */
11
+ function resolveConfig(opts = {}) {
12
+ const stored = loadConfig();
13
+ const resolved = { ...stored };
14
+ if (opts.chrome) resolved.chromePath = opts.chrome;
15
+ if (opts.profile) resolved.profileDir = path.resolve(opts.profile);
16
+ if (opts.bundle) resolved.bundlePath = path.resolve(opts.bundle);
17
+ if (opts.port !== undefined) resolved.cdpPort = parsePort(opts.port);
18
+ return resolved;
19
+ }
20
+
21
+ function parsePort(value) {
22
+ const port = Number(value);
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
24
+ throw new Error(`Invalid --port "${value}". Use an integer 1-65535.`);
25
+ }
26
+ return port;
27
+ }
28
+
29
+ function parseTimeoutMs(value, fallbackSeconds = 30) {
30
+ const seconds = value === undefined ? fallbackSeconds : Number(value);
31
+ if (!Number.isFinite(seconds) || seconds <= 0) {
32
+ throw new Error(`Invalid --timeout "${value}". Use seconds > 0.`);
33
+ }
34
+ return seconds * 1000;
35
+ }
36
+
37
+ function normalizeUrl(url) {
38
+ return /^[a-z]+:\/\//i.test(url) ? url : `https://${url}`;
39
+ }
40
+
41
+ /** Create the bundle on demand (without forcing a rewrite of an existing one). */
42
+ function ensureBundleReady(config) {
43
+ const { created } = ensureAppBundle(config, { force: false });
44
+ if (created) {
45
+ console.error(`Created ${config.bundlePath} (Dock icon: Chrome).`);
46
+ }
47
+ }
48
+
49
+ // MARK: setup
50
+ export async function setupCommand(opts) {
51
+ assertSupportedPlatform();
52
+ const config = resolveConfig(opts);
53
+ const { created } = ensureAppBundle(config, { force: true });
54
+ const file = saveConfig(config);
55
+
56
+ console.error(`${created ? "Created" : "Repaired"} ${config.bundlePath}`);
57
+ console.error(` Chrome: ${config.chromePath}`);
58
+ console.error(` Profile: ${config.profileDir}`);
59
+ console.error(` CDP port: ${config.cdpPort}`);
60
+ console.error(`Config saved to ${file}`);
61
+ }
62
+
63
+ // MARK: open
64
+ export async function openCommand(url, opts) {
65
+ assertSupportedPlatform();
66
+ const config = resolveConfig(opts);
67
+ const headless = Boolean(opts.headless);
68
+ const timeoutMs = parseTimeoutMs(opts.timeout);
69
+
70
+ ensureBundleReady(config);
71
+
72
+ const { launched } = await ensureChromeRunning({
73
+ bundlePath: config.bundlePath,
74
+ cdpPort: config.cdpPort,
75
+ headless,
76
+ timeoutMs,
77
+ });
78
+ console.error(
79
+ launched
80
+ ? `Launched ChromeCDP (${headless ? "headless" : "headed"}) on port ${config.cdpPort}.`
81
+ : `ChromeCDP already running on port ${config.cdpPort}.`,
82
+ );
83
+
84
+ if (url) {
85
+ const target = await openUrl(config.cdpPort, normalizeUrl(url));
86
+ console.error(`Opened ${normalizeUrl(url)} (target ${target}).`);
87
+ }
88
+ console.error(`DevTools: http://127.0.0.1:${config.cdpPort}/json`);
89
+ }
90
+
91
+ // MARK: html
92
+ export async function htmlCommand(url, opts) {
93
+ assertSupportedPlatform();
94
+ const config = resolveConfig(opts);
95
+ const headless = opts.headless === undefined ? true : Boolean(opts.headless);
96
+ const timeoutMs = parseTimeoutMs(opts.timeout);
97
+ const target = normalizeUrl(url);
98
+
99
+ ensureBundleReady(config);
100
+ await ensureChromeRunning({
101
+ bundlePath: config.bundlePath,
102
+ cdpPort: config.cdpPort,
103
+ headless,
104
+ timeoutMs,
105
+ });
106
+
107
+ console.error(`Fetching HTML for ${target} ...`);
108
+ const html = await getPageHtml(config.cdpPort, target, {
109
+ close: Boolean(opts.close),
110
+ timeoutMs,
111
+ });
112
+
113
+ if (opts.output) {
114
+ const outPath = path.resolve(opts.output);
115
+ fs.writeFileSync(outPath, html);
116
+ console.error(`Saved HTML to ${outPath}`);
117
+ } else {
118
+ process.stdout.write(html.endsWith("\n") ? html : `${html}\n`);
119
+ }
120
+ }
121
+
122
+ // MARK: status
123
+ export async function statusCommand(opts) {
124
+ assertSupportedPlatform();
125
+ const config = resolveConfig(opts);
126
+ const exists = bundleExists(config.bundlePath);
127
+ const version = await probeCdp(config.cdpPort);
128
+
129
+ console.log("ChromeCDP status");
130
+ console.log(` Bundle: ${exists ? "present" : "missing"} (${config.bundlePath})`);
131
+ console.log(` Profile: ${config.profileDir}`);
132
+ console.log(` CDP port: ${config.cdpPort}`);
133
+ console.log(
134
+ version
135
+ ? ` Running: yes — ${version.Browser} (${version["Protocol-Version"] ?? "?"})`
136
+ : " Running: no",
137
+ );
138
+ console.log(` Config: ${CONFIG_FILE}`);
139
+ }
140
+
141
+ // MARK: stop
142
+ export async function stopCommand(opts) {
143
+ assertSupportedPlatform();
144
+ const config = resolveConfig(opts);
145
+ const version = await probeCdp(config.cdpPort);
146
+ if (!version) {
147
+ console.error(`No CDP instance reachable on port ${config.cdpPort}.`);
148
+ return;
149
+ }
150
+ await closeBrowser(config.cdpPort);
151
+ console.error(`Asked ChromeCDP on port ${config.cdpPort} to quit.`);
152
+ }
153
+
154
+ export { DEFAULTS };
package/src/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Default, canonical values for a ChromeCDP environment.
7
+ *
8
+ * These are baked into the ChromeCDP.app bundle at setup time and persisted to
9
+ * a config file so every subcommand agrees on the same port/profile/binary.
10
+ */
11
+ export const DEFAULTS = Object.freeze({
12
+ chromePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
13
+ chromeIcon: "/Applications/Google Chrome.app/Contents/Resources/app.icns",
14
+ bundlePath: "/Applications/ChromeCDP.app",
15
+ bundleId: "org.guocity.chrome-cdp",
16
+ cdpPort: 9222,
17
+ profileDir: path.join(os.homedir(), ".chrome_cdp_profile"),
18
+ });
19
+
20
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "chrome-cdp-manager");
21
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
22
+
23
+ /** Load persisted config, falling back to {@link DEFAULTS} for missing keys. */
24
+ export function loadConfig() {
25
+ let stored = {};
26
+ try {
27
+ stored = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
28
+ } catch {
29
+ // No config yet — first run.
30
+ }
31
+ return { ...DEFAULTS, ...stored };
32
+ }
33
+
34
+ /** Persist the resolved config so other commands stay in sync with the bundle. */
35
+ export function saveConfig(config) {
36
+ const toStore = {
37
+ chromePath: config.chromePath,
38
+ chromeIcon: config.chromeIcon,
39
+ bundlePath: config.bundlePath,
40
+ bundleId: config.bundleId,
41
+ cdpPort: config.cdpPort,
42
+ profileDir: config.profileDir,
43
+ };
44
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
45
+ fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(toStore, null, 2)}\n`);
46
+ return CONFIG_FILE;
47
+ }
48
+
49
+ export { CONFIG_FILE };
@@ -0,0 +1,21 @@
1
+ import os from "node:os";
2
+
3
+ /**
4
+ * This tool builds a macOS `.app` bundle and relies on `arch -arm64` plus the
5
+ * `open` LaunchServices command, so it only runs on Apple Silicon macOS.
6
+ */
7
+ export function assertSupportedPlatform() {
8
+ if (process.platform !== "darwin") {
9
+ throw new Error(
10
+ `chrome-cdp-manager only supports macOS (found "${process.platform}").`,
11
+ );
12
+ }
13
+ if (os.arch() !== "arm64") {
14
+ // Not fatal — the launcher uses `arch -arm64`, which is a no-op on arm64
15
+ // and an error on Intel. Warn rather than block in case of Rosetta setups.
16
+ process.emitWarning(
17
+ `Detected CPU arch "${os.arch()}". The launcher uses \`arch -arm64\`; ` +
18
+ `this is intended for Apple Silicon Macs.`,
19
+ );
20
+ }
21
+ }