cdp-toolkit 1.10.0 → 1.11.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/dist/cli.js CHANGED
@@ -5396,6 +5396,252 @@ async function connectBidiSessionUrl(wsUrl, opts = {}) {
5396
5396
  return conn;
5397
5397
  }
5398
5398
 
5399
+ // src/bidi/session-lease.ts
5400
+ import { mkdir as mkdir12, readFile as readFile8, unlink as unlink5, writeFile as writeFile9 } from "node:fs/promises";
5401
+ import { join as join10 } from "node:path";
5402
+ var DEFAULT_SESSION_WAIT_MS = 1e4;
5403
+ var DEFAULT_POLL_MS = 250;
5404
+ function safeEndpoint(endpoint) {
5405
+ return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
5406
+ }
5407
+ function sessionSlotFile(endpoint) {
5408
+ return join10(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
5409
+ }
5410
+ function sessionWaitMs() {
5411
+ const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
5412
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
5413
+ }
5414
+
5415
+ class SessionSlotBusyError extends Error {
5416
+ endpoint;
5417
+ holderPid;
5418
+ holderLabel;
5419
+ constructor(endpoint, holder) {
5420
+ const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
5421
+ super(`Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` + `Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` + `by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` + `this server at a different endpoint/browser.`);
5422
+ this.name = "SessionSlotBusyError";
5423
+ this.endpoint = endpoint;
5424
+ if (holder) {
5425
+ this.holderPid = holder.pid;
5426
+ this.holderLabel = holder.label;
5427
+ }
5428
+ }
5429
+ }
5430
+ async function readSessionSlot(endpoint) {
5431
+ let raw;
5432
+ try {
5433
+ raw = await readFile8(sessionSlotFile(endpoint), "utf8");
5434
+ } catch (err) {
5435
+ if (err.code === "ENOENT")
5436
+ return;
5437
+ throw err;
5438
+ }
5439
+ try {
5440
+ const rec = JSON.parse(raw);
5441
+ if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
5442
+ return rec;
5443
+ }
5444
+ } catch {}
5445
+ return;
5446
+ }
5447
+ async function acquireSessionSlot(endpoint, opts) {
5448
+ const waitMs = opts.waitMs ?? sessionWaitMs();
5449
+ const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
5450
+ const createdAt = opts.now ?? Date.now();
5451
+ const file = sessionSlotFile(endpoint);
5452
+ const record2 = {
5453
+ endpoint,
5454
+ pid: process.pid,
5455
+ label: opts.label,
5456
+ createdAt
5457
+ };
5458
+ if (opts.marionettePort !== undefined)
5459
+ record2.marionettePort = opts.marionettePort;
5460
+ const handle = { endpoint, pid: process.pid, createdAt };
5461
+ const payload = JSON.stringify(record2);
5462
+ await mkdir12(leaseDir(), { recursive: true });
5463
+ const deadline = Date.now() + waitMs;
5464
+ const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
5465
+ let attempts = 0;
5466
+ let lastLiveHolder;
5467
+ while (true) {
5468
+ if (attempts >= maxAttempts)
5469
+ throw new SessionSlotBusyError(endpoint, lastLiveHolder);
5470
+ attempts++;
5471
+ try {
5472
+ await writeFile9(file, payload, { flag: "wx" });
5473
+ return { handle };
5474
+ } catch (err) {
5475
+ if (err.code !== "EEXIST")
5476
+ throw err;
5477
+ }
5478
+ const holder = await readSessionSlot(endpoint);
5479
+ if (!holder)
5480
+ continue;
5481
+ if (!isPidAlive(holder.pid)) {
5482
+ await unlink5(file).catch(() => {
5483
+ return;
5484
+ });
5485
+ try {
5486
+ await writeFile9(file, payload, { flag: "wx" });
5487
+ return { handle, staleHolder: holder };
5488
+ } catch (err) {
5489
+ if (err.code !== "EEXIST")
5490
+ throw err;
5491
+ continue;
5492
+ }
5493
+ }
5494
+ lastLiveHolder = holder;
5495
+ const remaining = deadline - Date.now();
5496
+ if (remaining <= 0)
5497
+ throw new SessionSlotBusyError(endpoint, holder);
5498
+ await sleep2(Math.min(pollMs, remaining));
5499
+ }
5500
+ }
5501
+ async function releaseSessionSlot(handle) {
5502
+ const current = await readSessionSlot(handle.endpoint);
5503
+ if (!current)
5504
+ return { released: false };
5505
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt)
5506
+ return { released: false };
5507
+ await unlink5(sessionSlotFile(handle.endpoint)).catch(() => {
5508
+ return;
5509
+ });
5510
+ return { released: true };
5511
+ }
5512
+ function sleep2(ms) {
5513
+ return new Promise((resolve) => setTimeout(resolve, ms));
5514
+ }
5515
+
5516
+ // src/bidi/marionette.ts
5517
+ import { Socket } from "node:net";
5518
+ var DEFAULT_MARIONETTE_PORT = 2828;
5519
+ var DEFAULT_TIMEOUT_MS3 = 4000;
5520
+ var HOST = "127.0.0.1";
5521
+ var COLON = 58;
5522
+ var DELETE_SESSION_FRAME = encodeFrame([0, 1, "WebDriver:DeleteSession", {}]);
5523
+ function defaultMarionettePort() {
5524
+ const raw = Number(process.env.CDP_FIREFOX_MARIONETTE_PORT);
5525
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MARIONETTE_PORT;
5526
+ }
5527
+ function encodeFrame(json) {
5528
+ const body = Buffer.from(JSON.stringify(json), "utf8");
5529
+ const prefix = Buffer.from(`${body.length}:`, "ascii");
5530
+ return Buffer.concat([prefix, body]);
5531
+ }
5532
+
5533
+ class MarionetteProtocolError extends Error {
5534
+ constructor(message) {
5535
+ super(message);
5536
+ this.name = "MarionetteProtocolError";
5537
+ }
5538
+ }
5539
+
5540
+ class FrameDecoder {
5541
+ buf = Buffer.alloc(0);
5542
+ push(chunk) {
5543
+ this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
5544
+ }
5545
+ next() {
5546
+ const colon = this.buf.indexOf(COLON);
5547
+ if (colon === -1) {
5548
+ this.assertDigitsPrefix(this.buf.length);
5549
+ return;
5550
+ }
5551
+ if (colon === 0)
5552
+ throw new MarionetteProtocolError("empty frame length prefix");
5553
+ this.assertDigitsPrefix(colon);
5554
+ const len = Number.parseInt(this.buf.toString("ascii", 0, colon), 10);
5555
+ const start = colon + 1;
5556
+ const end = start + len;
5557
+ if (this.buf.length < end)
5558
+ return;
5559
+ const payload = this.buf.toString("utf8", start, end);
5560
+ this.buf = this.buf.subarray(end);
5561
+ try {
5562
+ return JSON.parse(payload);
5563
+ } catch {
5564
+ throw new MarionetteProtocolError(`frame body was not valid JSON (${len} bytes): ${payload.slice(0, 80)}`);
5565
+ }
5566
+ }
5567
+ assertDigitsPrefix(upTo) {
5568
+ for (let i = 0;i < upTo; i++) {
5569
+ const b = this.buf[i];
5570
+ if (b < 48 || b > 57) {
5571
+ throw new MarionetteProtocolError(`non-digit byte 0x${b.toString(16)} in frame length prefix`);
5572
+ }
5573
+ }
5574
+ }
5575
+ }
5576
+ function isSlotFreedReply(frame) {
5577
+ if (!Array.isArray(frame))
5578
+ return false;
5579
+ const err = frame[2];
5580
+ if (err === null)
5581
+ return true;
5582
+ if (typeof err === "object" && err !== null && err.error === "invalid session id") {
5583
+ return true;
5584
+ }
5585
+ return false;
5586
+ }
5587
+ function forceClearOrphanSession(marionettePort, opts = {}) {
5588
+ const timeoutMs = opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS3;
5589
+ return new Promise((resolve) => {
5590
+ if (!Number.isFinite(marionettePort) || marionettePort <= 0 || marionettePort > 65535) {
5591
+ resolve(false);
5592
+ return;
5593
+ }
5594
+ const sock = new Socket;
5595
+ const decoder = new FrameDecoder;
5596
+ let settled = false;
5597
+ let handshakeSeen = false;
5598
+ let commandSent = false;
5599
+ const settle = (value) => {
5600
+ if (settled)
5601
+ return;
5602
+ settled = true;
5603
+ clearTimeout(timer);
5604
+ sock.removeAllListeners();
5605
+ sock.destroy();
5606
+ resolve(value);
5607
+ };
5608
+ const timer = setTimeout(() => settle(false), timeoutMs);
5609
+ if (typeof timer.unref === "function")
5610
+ timer.unref();
5611
+ sock.on("error", () => settle(false));
5612
+ sock.on("close", () => settle(false));
5613
+ sock.on("data", (chunk) => {
5614
+ if (settled)
5615
+ return;
5616
+ try {
5617
+ decoder.push(chunk);
5618
+ for (;; ) {
5619
+ const frame = decoder.next();
5620
+ if (frame === undefined)
5621
+ break;
5622
+ if (!handshakeSeen) {
5623
+ handshakeSeen = true;
5624
+ continue;
5625
+ }
5626
+ settle(isSlotFreedReply(frame));
5627
+ return;
5628
+ }
5629
+ if (handshakeSeen && !commandSent) {
5630
+ commandSent = true;
5631
+ sock.write(DELETE_SESSION_FRAME);
5632
+ }
5633
+ } catch {
5634
+ settle(false);
5635
+ }
5636
+ });
5637
+ try {
5638
+ sock.connect(marionettePort, HOST);
5639
+ } catch {
5640
+ settle(false);
5641
+ }
5642
+ });
5643
+ }
5644
+
5399
5645
  // src/bidi/snapshot.ts
5400
5646
  var STAMP_ATTR = "data-cdp-uid";
5401
5647
  async function takeStampedSnapshot(evaluate) {
@@ -5972,6 +6218,22 @@ var BIDI_CAPABILITIES = new Set([
5972
6218
  "locate.xpath"
5973
6219
  ]);
5974
6220
  var connections = new Map;
6221
+ var slotHandles = new Map;
6222
+ var connecting = new Map;
6223
+ async function releaseSlotOnDialFailure(endpoint, owned) {
6224
+ if (owned)
6225
+ return;
6226
+ const h = slotHandles.get(endpoint);
6227
+ if (h) {
6228
+ await releaseSessionSlot(h).catch(() => {
6229
+ return;
6230
+ });
6231
+ slotHandles.delete(endpoint);
6232
+ }
6233
+ }
6234
+ function orphanNotClearedError(endpoint) {
6235
+ return driverError2("disconnected", `Firefox holds an orphaned WebDriver BiDi session on ${endpoint} that could not be auto-cleared. ` + `Marionette recovery needs Firefox launched with --marionette (default port 2828, or set ` + `CDP_FIREFOX_MARIONETTE_PORT); otherwise restart Firefox to free the slot. (A client killed without ` + `session.end can leave a session Firefox does not reap.)`);
6236
+ }
5975
6237
  async function getConnection(endpoint, timeoutMs) {
5976
6238
  const existing = connections.get(endpoint);
5977
6239
  if (existing && existing.isOpen) {
@@ -5980,17 +6242,68 @@ async function getConnection(endpoint, timeoutMs) {
5980
6242
  }
5981
6243
  if (existing)
5982
6244
  connections.delete(endpoint);
5983
- const conn = await connectBidiSessionUrl(endpoint, {
5984
- ...timeoutMs !== undefined ? { timeoutMs } : {},
5985
- capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
5986
- }).catch((e) => {
5987
- if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
5988
- throw driverError2("disconnected", `Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` + `Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` + `session that Firefox does not reap.)`);
6245
+ const inflight = connecting.get(endpoint);
6246
+ if (inflight) {
6247
+ const conn = await inflight;
6248
+ conn.retain();
6249
+ return conn;
6250
+ }
6251
+ const create = (async () => {
6252
+ const mport = defaultMarionettePort();
6253
+ const owned = slotHandles.has(endpoint);
6254
+ if (!owned) {
6255
+ let acq;
6256
+ try {
6257
+ acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
6258
+ } catch (e) {
6259
+ if (e instanceof SessionSlotBusyError) {
6260
+ const who = e.holderLabel ? `'${e.holderLabel}' (pid ${e.holderPid})` : "another live process";
6261
+ throw driverError2("disconnected", `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}. ` + `Firefox serves exactly ONE BiDi session per browser instance, so this server cannot take it ` + `while that process is running. Wait for it to release the session and retry, or point this ` + `server at a different endpoint/browser.`);
6262
+ }
6263
+ throw e;
6264
+ }
6265
+ slotHandles.set(endpoint, acq.handle);
6266
+ if (acq.staleHolder) {
6267
+ await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
6268
+ }
5989
6269
  }
5990
- throw e;
5991
- });
5992
- connections.set(endpoint, conn);
5993
- return conn;
6270
+ const dialOpts = {
6271
+ ...timeoutMs !== undefined ? { timeoutMs } : {},
6272
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
6273
+ };
6274
+ let conn;
6275
+ try {
6276
+ conn = await connectBidiSessionUrl(endpoint, dialOpts);
6277
+ } catch (e) {
6278
+ if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
6279
+ const cleared = await forceClearOrphanSession(mport);
6280
+ if (!cleared) {
6281
+ await releaseSlotOnDialFailure(endpoint, owned);
6282
+ throw orphanNotClearedError(endpoint);
6283
+ }
6284
+ try {
6285
+ conn = await connectBidiSessionUrl(endpoint, dialOpts);
6286
+ } catch (e2) {
6287
+ await releaseSlotOnDialFailure(endpoint, owned);
6288
+ if (e2 instanceof BidiError && /maximum number of active sessions/i.test(e2.message)) {
6289
+ throw orphanNotClearedError(endpoint);
6290
+ }
6291
+ throw e2;
6292
+ }
6293
+ } else {
6294
+ await releaseSlotOnDialFailure(endpoint, owned);
6295
+ throw e;
6296
+ }
6297
+ }
6298
+ connections.set(endpoint, conn);
6299
+ return conn;
6300
+ })();
6301
+ connecting.set(endpoint, create);
6302
+ try {
6303
+ return await create;
6304
+ } finally {
6305
+ connecting.delete(endpoint);
6306
+ }
5994
6307
  }
5995
6308
  var bidiBeaconScripts = new Map;
5996
6309
  async function safeTitle(conn, contextId) {
@@ -6756,14 +7069,27 @@ class BidiBrowserDriver {
6756
7069
  if (key.startsWith(`${this.endpoint}:`))
6757
7070
  bidiBeaconScripts.delete(key);
6758
7071
  }
7072
+ connecting.delete(this.endpoint);
6759
7073
  const conn = connections.get(this.endpoint);
6760
- if (!conn)
7074
+ if (!conn) {
7075
+ await this.releaseSessionSlotIfHeld();
6761
7076
  return;
7077
+ }
6762
7078
  connections.delete(this.endpoint);
6763
7079
  await conn.send("session.end", {}).catch(() => {
6764
7080
  return;
6765
7081
  });
6766
7082
  conn.dispose();
7083
+ await this.releaseSessionSlotIfHeld();
7084
+ }
7085
+ async releaseSessionSlotIfHeld() {
7086
+ const h = slotHandles.get(this.endpoint);
7087
+ if (!h)
7088
+ return;
7089
+ await releaseSessionSlot(h).catch(() => {
7090
+ return;
7091
+ });
7092
+ slotHandles.delete(this.endpoint);
6767
7093
  }
6768
7094
  }
6769
7095
  function createFirefoxDriver(port, opts) {
@@ -6778,7 +7104,7 @@ import { spawn as spawn3 } from "node:child_process";
6778
7104
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
6779
7105
  import { createConnection, createServer } from "node:net";
6780
7106
  import { tmpdir } from "node:os";
6781
- import { join as join10 } from "node:path";
7107
+ import { join as join11 } from "node:path";
6782
7108
  function resolveBinary() {
6783
7109
  const candidates = [
6784
7110
  process.env.FIREFOX_BIN,
@@ -6856,7 +7182,7 @@ async function launchFirefox(opts = {}) {
6856
7182
  const binary = resolveBinary();
6857
7183
  const port = await pickPort(opts.port);
6858
7184
  const isThrowaway = opts.profilePath === undefined;
6859
- const profilePath = opts.profilePath ?? mkdtempSync(join10(tmpdir(), "cdp-toolkit-ff-profile-"));
7185
+ const profilePath = opts.profilePath ?? mkdtempSync(join11(tmpdir(), "cdp-toolkit-ff-profile-"));
6860
7186
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
6861
7187
  if (opts.headless)
6862
7188
  args.push("--headless");
@@ -8886,6 +9212,566 @@ var MANIFEST = [
8886
9212
  }
8887
9213
  ];
8888
9214
 
9215
+ // src/install/wizard.ts
9216
+ import { createInterface } from "node:readline";
9217
+ import { homedir as homedir3 } from "node:os";
9218
+ import { join as join14 } from "node:path";
9219
+
9220
+ // src/install/browser.ts
9221
+ import { existsSync as existsSync2 } from "node:fs";
9222
+ import { mkdir as mkdir13, readFile as readFile9, writeFile as writeFile10 } from "node:fs/promises";
9223
+ import { homedir } from "node:os";
9224
+ import { delimiter, dirname as dirname3, join as join12 } from "node:path";
9225
+ var CANDIDATE_BUILDERS = {
9226
+ chrome: () => [
9227
+ process.env.CHROME_BIN,
9228
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
9229
+ "google-chrome",
9230
+ "google-chrome-stable",
9231
+ "chromium"
9232
+ ],
9233
+ arc: () => [process.env.ARC_BIN, "/Applications/Arc.app/Contents/MacOS/Arc", "arc"],
9234
+ firefox: () => [
9235
+ process.env.FIREFOX_BIN,
9236
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
9237
+ "firefox",
9238
+ "/usr/bin/firefox"
9239
+ ]
9240
+ };
9241
+ function existsOnPath(cmd) {
9242
+ const pathEnv = process.env.PATH;
9243
+ if (!pathEnv)
9244
+ return false;
9245
+ for (const dir of pathEnv.split(delimiter)) {
9246
+ if (!dir)
9247
+ continue;
9248
+ if (existsSync2(join12(dir, cmd)))
9249
+ return true;
9250
+ }
9251
+ return false;
9252
+ }
9253
+ function resolveCandidate(candidates) {
9254
+ let lastDefined = "";
9255
+ for (const c of candidates) {
9256
+ if (!c)
9257
+ continue;
9258
+ lastDefined = c;
9259
+ if (c.startsWith("/")) {
9260
+ if (existsSync2(c))
9261
+ return { binary: c, found: true };
9262
+ continue;
9263
+ }
9264
+ if (existsOnPath(c))
9265
+ return { binary: c, found: true };
9266
+ }
9267
+ return { binary: lastDefined, found: false };
9268
+ }
9269
+ function detectBrowser(choice) {
9270
+ const { binary, found } = resolveCandidate(CANDIDATE_BUILDERS[choice]());
9271
+ return { choice, binary, found };
9272
+ }
9273
+ function defaultProfileDir(choice) {
9274
+ return `$HOME/.cdp-toolkit/${choice}-profile`;
9275
+ }
9276
+ function defaultAliasName(choice) {
9277
+ return `cdp-${choice}`;
9278
+ }
9279
+ function browserLaunchArgs(o) {
9280
+ const profileDir = o.profileDir ?? defaultProfileDir(o.choice);
9281
+ if (o.choice === "firefox") {
9282
+ return ["--remote-debugging-port", String(o.port), "--marionette", "--no-remote", "--profile", profileDir];
9283
+ }
9284
+ return [`--remote-debugging-port=${o.port}`, `--user-data-dir=${profileDir}`];
9285
+ }
9286
+ function shellQuoteIfSpaced(token) {
9287
+ return /\s/.test(token) ? `"${token}"` : token;
9288
+ }
9289
+ function aliasLine(o) {
9290
+ const name = o.aliasName ?? defaultAliasName(o.choice);
9291
+ const { binary } = detectBrowser(o.choice);
9292
+ const parts = [shellQuoteIfSpaced(binary), ...browserLaunchArgs(o)];
9293
+ return `alias ${name}='${parts.join(" ")}'`;
9294
+ }
9295
+ function detectShellRc() {
9296
+ const shell = process.env.SHELL ?? "";
9297
+ if (shell.endsWith("zsh"))
9298
+ return join12(homedir(), ".zshrc");
9299
+ if (shell.endsWith("bash"))
9300
+ return join12(homedir(), ".bashrc");
9301
+ return join12(homedir(), process.platform === "darwin" ? ".zshrc" : ".bashrc");
9302
+ }
9303
+ var MARKER_START = "# >>> cdp-toolkit alias >>>";
9304
+ var MARKER_END = "# <<< cdp-toolkit alias <<<";
9305
+ function ensureTrailingNewline(s) {
9306
+ return s.endsWith(`
9307
+ `) ? s : `${s}
9308
+ `;
9309
+ }
9310
+ async function appendAliasToRc(rcPath, aliasBlockBody) {
9311
+ let existing = "";
9312
+ try {
9313
+ existing = await readFile9(rcPath, "utf8");
9314
+ } catch (err) {
9315
+ if (err.code !== "ENOENT")
9316
+ throw err;
9317
+ }
9318
+ const block = `${MARKER_START}
9319
+ ${aliasBlockBody}
9320
+ ${MARKER_END}`;
9321
+ const startIdx = existing.indexOf(MARKER_START);
9322
+ const endIdx = existing.indexOf(MARKER_END);
9323
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
9324
+ const endOfEndLine = existing.indexOf(`
9325
+ `, endIdx);
9326
+ const before = existing.slice(0, startIdx);
9327
+ const after = endOfEndLine === -1 ? "" : existing.slice(endOfEndLine + 1);
9328
+ const existingBlock = existing.slice(startIdx, endOfEndLine === -1 ? existing.length : endOfEndLine);
9329
+ if (existingBlock === block) {
9330
+ return { rcPath, action: "unchanged" };
9331
+ }
9332
+ const next2 = ensureTrailingNewline(`${before}${block}
9333
+ ${after}`);
9334
+ await writeFile10(rcPath, next2, "utf8");
9335
+ return { rcPath, action: "replaced-block" };
9336
+ }
9337
+ await mkdir13(dirname3(rcPath), { recursive: true });
9338
+ const sep = existing.length === 0 || existing.endsWith(`
9339
+ `) ? "" : `
9340
+ `;
9341
+ const next = `${existing}${sep}${block}
9342
+ `;
9343
+ await writeFile10(rcPath, next, "utf8");
9344
+ return { rcPath, action: "created-block" };
9345
+ }
9346
+
9347
+ // src/install/harness.ts
9348
+ import { spawnSync } from "node:child_process";
9349
+ import { mkdir as mkdir14, readFile as readFile10, writeFile as writeFile11 } from "node:fs/promises";
9350
+ import { homedir as homedir2 } from "node:os";
9351
+ import { dirname as dirname4, join as join13 } from "node:path";
9352
+ async function installMcpServer(harness, name, cfg, opts = {}) {
9353
+ switch (harness) {
9354
+ case "claude":
9355
+ return installClaude(name, cfg, opts);
9356
+ case "codex":
9357
+ return installCodex(name, cfg);
9358
+ case "opencode":
9359
+ return installOpencode(name, cfg);
9360
+ default: {
9361
+ const exhaustive = harness;
9362
+ throw new Error(`unknown harness: ${String(exhaustive)}`);
9363
+ }
9364
+ }
9365
+ }
9366
+ async function installClaude(name, cfg, opts) {
9367
+ const path = opts.configPath ?? join13(opts.homeDir ?? homedir2(), ".claude.json");
9368
+ let root;
9369
+ let fileExisted;
9370
+ try {
9371
+ const text = await readFile10(path, "utf8");
9372
+ const parsed = JSON.parse(text);
9373
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
9374
+ throw new Error(`${path} does not contain a JSON object at its top level`);
9375
+ }
9376
+ root = parsed;
9377
+ fileExisted = true;
9378
+ } catch (err) {
9379
+ if (err.code === "ENOENT") {
9380
+ root = {};
9381
+ fileExisted = false;
9382
+ } else {
9383
+ throw err;
9384
+ }
9385
+ }
9386
+ const rawServers = root.mcpServers;
9387
+ const mcpServers = typeof rawServers === "object" && rawServers !== null && !Array.isArray(rawServers) ? rawServers : {};
9388
+ root.mcpServers = mcpServers;
9389
+ const entry = { command: cfg.command, args: cfg.args };
9390
+ if (Object.keys(cfg.env).length > 0)
9391
+ entry.env = cfg.env;
9392
+ const prior = mcpServers[name];
9393
+ const action = prior === undefined ? "added" : canonicalJson(prior) === canonicalJson(entry) ? "unchanged" : "updated";
9394
+ mcpServers[name] = entry;
9395
+ if (action === "unchanged") {
9396
+ return { harness: "claude", method: "file", path, action, detail: "no change needed" };
9397
+ }
9398
+ await mkdir14(dirname4(path), { recursive: true });
9399
+ await writeFile11(path, JSON.stringify(root, null, 2) + `
9400
+ `, "utf8");
9401
+ return {
9402
+ harness: "claude",
9403
+ method: "file",
9404
+ path,
9405
+ action,
9406
+ detail: fileExisted ? undefined : "created new config file"
9407
+ };
9408
+ }
9409
+ function canonicalJson(value) {
9410
+ return JSON.stringify(canonicalize(value));
9411
+ }
9412
+ function canonicalize(value) {
9413
+ if (Array.isArray(value))
9414
+ return value.map(canonicalize);
9415
+ if (value !== null && typeof value === "object") {
9416
+ const out = {};
9417
+ for (const key of Object.keys(value).sort()) {
9418
+ out[key] = canonicalize(value[key]);
9419
+ }
9420
+ return out;
9421
+ }
9422
+ return value;
9423
+ }
9424
+ function codexAddArgs(name, cfg) {
9425
+ const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
9426
+ return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
9427
+ }
9428
+ async function installCodex(name, cfg) {
9429
+ if (!isOnPath("codex")) {
9430
+ throw new Error("codex CLI not found on PATH — install Codex or pick a different harness");
9431
+ }
9432
+ const probe = spawnSync("codex", ["mcp", "get", name], { encoding: "utf8" });
9433
+ const exists = probe.status === 0;
9434
+ if (exists) {
9435
+ const removed = spawnSync("codex", ["mcp", "remove", name], { encoding: "utf8" });
9436
+ if (removed.status !== 0) {
9437
+ throw new Error(`codex mcp remove ${name} failed: ${(removed.stderr || removed.stdout || "").trim()}`);
9438
+ }
9439
+ }
9440
+ const addArgs = codexAddArgs(name, cfg);
9441
+ const added = spawnSync("codex", addArgs, { encoding: "utf8" });
9442
+ if (added.status !== 0) {
9443
+ throw new Error(`codex ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
9444
+ }
9445
+ return {
9446
+ harness: "codex",
9447
+ method: "cli",
9448
+ action: exists ? "updated" : "added",
9449
+ detail: added.stdout?.trim() || undefined
9450
+ };
9451
+ }
9452
+ function opencodeAddArgs(name, cfg) {
9453
+ const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
9454
+ return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
9455
+ }
9456
+ async function installOpencode(name, cfg) {
9457
+ if (!isOnPath("opencode")) {
9458
+ throw new Error("opencode CLI not found on PATH — install OpenCode or pick a different harness");
9459
+ }
9460
+ const addArgs = opencodeAddArgs(name, cfg);
9461
+ const added = spawnSync("opencode", addArgs, { encoding: "utf8" });
9462
+ if (added.status !== 0) {
9463
+ throw new Error(`opencode ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
9464
+ }
9465
+ return {
9466
+ harness: "opencode",
9467
+ method: "cli",
9468
+ action: "added",
9469
+ detail: added.stdout?.trim() || undefined
9470
+ };
9471
+ }
9472
+ function isOnPath(bin) {
9473
+ const finder = process.platform === "win32" ? "where" : "which";
9474
+ const result = spawnSync(finder, [bin], { encoding: "utf8" });
9475
+ return result.status === 0;
9476
+ }
9477
+
9478
+ // src/install/wizard.ts
9479
+ var DEFAULT_NAME = "cdp-toolkit";
9480
+ var HARNESSES = ["claude", "codex", "opencode"];
9481
+ var BROWSERS = ["arc", "chrome", "firefox"];
9482
+ function defaultPortFor(browser) {
9483
+ return browser === "firefox" ? 9223 : 9222;
9484
+ }
9485
+ function parseHarness(v) {
9486
+ if (v === undefined)
9487
+ throw new Error("--harness requires a value: claude, codex, or opencode");
9488
+ if (HARNESSES.includes(v))
9489
+ return v;
9490
+ throw new Error(`--harness must be one of claude|codex|opencode (got '${v}')`);
9491
+ }
9492
+ function parseBrowserChoice(v) {
9493
+ if (v === undefined)
9494
+ throw new Error("--browser requires a value: arc, chrome, or firefox");
9495
+ if (BROWSERS.includes(v))
9496
+ return v;
9497
+ throw new Error(`--browser must be one of arc|chrome|firefox (got '${v}')`);
9498
+ }
9499
+ function parsePort(v) {
9500
+ if (v === undefined)
9501
+ throw new Error("--port requires a value (an integer 1-65535)");
9502
+ const n = Number(v);
9503
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
9504
+ throw new Error(`--port must be an integer 1-65535 (got '${v}')`);
9505
+ }
9506
+ return n;
9507
+ }
9508
+ function normalizeArgv(argv) {
9509
+ const out = [];
9510
+ for (const t of argv) {
9511
+ if (t.startsWith("--") && t.includes("=")) {
9512
+ const eq = t.indexOf("=");
9513
+ out.push(t.slice(0, eq), t.slice(eq + 1));
9514
+ } else {
9515
+ out.push(t);
9516
+ }
9517
+ }
9518
+ return out;
9519
+ }
9520
+ function parseInstallArgs(argv) {
9521
+ const tokens = normalizeArgv(argv);
9522
+ const out = { name: DEFAULT_NAME, alias: true };
9523
+ for (let i = 0;i < tokens.length; i++) {
9524
+ const t = tokens[i];
9525
+ switch (t) {
9526
+ case "--help":
9527
+ case "-h":
9528
+ out.help = true;
9529
+ break;
9530
+ case "--yes":
9531
+ case "-y":
9532
+ out.yes = true;
9533
+ break;
9534
+ case "--no-alias":
9535
+ out.alias = false;
9536
+ break;
9537
+ case "--alias":
9538
+ out.alias = true;
9539
+ break;
9540
+ case "--harness":
9541
+ out.harness = parseHarness(tokens[++i]);
9542
+ break;
9543
+ case "--browser":
9544
+ out.browser = parseBrowserChoice(tokens[++i]);
9545
+ break;
9546
+ case "--port":
9547
+ out.port = parsePort(tokens[++i]);
9548
+ break;
9549
+ case "--name": {
9550
+ const v = tokens[++i];
9551
+ if (v === undefined)
9552
+ throw new Error("--name requires a value");
9553
+ out.name = v;
9554
+ break;
9555
+ }
9556
+ case "--alias-name": {
9557
+ const v = tokens[++i];
9558
+ if (v === undefined)
9559
+ throw new Error("--alias-name requires a value");
9560
+ out.aliasName = v;
9561
+ break;
9562
+ }
9563
+ default:
9564
+ throw new Error(`cdp install: unknown option '${t}' (run \`cdp install --help\`)`);
9565
+ }
9566
+ }
9567
+ return out;
9568
+ }
9569
+ function composeServerConfig(browser, port) {
9570
+ const env = browser === "firefox" ? { CDP_BROWSER: "firefox", CDP_FIREFOX_ENDPOINT: String(port) } : { CDP_BASE: `http://127.0.0.1:${port}` };
9571
+ return { command: "npx", args: ["-y", "cdp-toolkit"], env };
9572
+ }
9573
+ function installHelpText() {
9574
+ return `cdp install — register the cdp-toolkit MCP server with your coding harness and add a
9575
+ browser-launch alias (with the remote-debugging port open) to your shell rc.
9576
+
9577
+ Usage:
9578
+ cdp install [--harness <claude|codex|opencode>] [--browser <arc|chrome|firefox>]
9579
+ [--port <n>] [--name <server-name>] [--alias-name <name>]
9580
+ [--no-alias] [--yes|-y] [--help|-h]
9581
+
9582
+ Options:
9583
+ --harness <h> Coding harness to register the MCP server with: claude, codex, opencode.
9584
+ --browser <b> Browser the alias launches: arc, chrome, firefox.
9585
+ --port <n> Remote-debugging port (1-65535). Default: 9222 (chrome/arc), 9223 (firefox).
9586
+ --name <name> MCP server name to register. Default: ${DEFAULT_NAME}.
9587
+ --alias-name <n> Shell alias name. Default: cdp-<browser> (e.g. cdp-firefox).
9588
+ --no-alias Register the MCP server only; skip writing the shell alias.
9589
+ --yes, -y Skip the confirmation prompt (proceed immediately).
9590
+ --help, -h Show this help.
9591
+
9592
+ Interactive: run \`cdp install\` in a terminal with no flags and it prompts for harness, browser,
9593
+ and port, then asks before writing anything.
9594
+
9595
+ Non-interactive (piped/CI): pass at least --harness and --browser (port defaults per browser); a
9596
+ missing required value fails fast with usage instead of hanging on stdin.`;
9597
+ }
9598
+ async function ask(question) {
9599
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9600
+ try {
9601
+ return await new Promise((resolve) => rl.question(question, resolve));
9602
+ } finally {
9603
+ rl.close();
9604
+ }
9605
+ }
9606
+ async function promptMenu(label, options) {
9607
+ const lines = [label];
9608
+ options.forEach((o, i) => lines.push(` ${i + 1}) ${o}`));
9609
+ process.stdout.write(`${lines.join(`
9610
+ `)}
9611
+ `);
9612
+ for (;; ) {
9613
+ const ans = (await ask(`Enter 1-${options.length}: `)).trim();
9614
+ const n = Number(ans);
9615
+ if (Number.isInteger(n) && n >= 1 && n <= options.length)
9616
+ return options[n - 1];
9617
+ process.stdout.write(`Please enter a number between 1 and ${options.length}.
9618
+ `);
9619
+ }
9620
+ }
9621
+ async function promptPort(def) {
9622
+ for (;; ) {
9623
+ const ans = (await ask(`Remote-debugging port [${def}]: `)).trim();
9624
+ if (ans === "")
9625
+ return def;
9626
+ const n = Number(ans);
9627
+ if (Number.isInteger(n) && n >= 1 && n <= 65535)
9628
+ return n;
9629
+ process.stdout.write(`Please enter an integer between 1 and 65535.
9630
+ `);
9631
+ }
9632
+ }
9633
+ function harnessTargetDesc(harness, io) {
9634
+ if (harness === "claude") {
9635
+ return io.configPath ?? join14(io.homeDir ?? homedir3(), ".claude.json");
9636
+ }
9637
+ return `via \`${harness} mcp add\``;
9638
+ }
9639
+ function binEnvVar(browser) {
9640
+ return `${browser.toUpperCase()}_BIN`;
9641
+ }
9642
+ async function runInstaller(argv, io = {}) {
9643
+ const write = io.stdout ?? ((s) => void process.stdout.write(s));
9644
+ const writeErr = io.stderr ?? ((s) => void process.stderr.write(s));
9645
+ const isTTY = io.isTTY ?? Boolean(process.stdin.isTTY);
9646
+ let args;
9647
+ try {
9648
+ args = parseInstallArgs(argv);
9649
+ } catch (err) {
9650
+ writeErr(`${err.message}
9651
+
9652
+ `);
9653
+ writeErr(`${installHelpText()}
9654
+ `);
9655
+ return 1;
9656
+ }
9657
+ if (args.help) {
9658
+ write(`${installHelpText()}
9659
+ `);
9660
+ return 0;
9661
+ }
9662
+ let harness = args.harness;
9663
+ let browser = args.browser;
9664
+ let port = args.port;
9665
+ if (isTTY) {
9666
+ if (harness === undefined)
9667
+ harness = await promptMenu("Which coding harness?", HARNESSES);
9668
+ if (browser === undefined)
9669
+ browser = await promptMenu("Which browser?", BROWSERS);
9670
+ if (port === undefined)
9671
+ port = await promptPort(defaultPortFor(browser));
9672
+ } else {
9673
+ const missing = [];
9674
+ if (harness === undefined)
9675
+ missing.push("--harness");
9676
+ if (browser === undefined)
9677
+ missing.push("--browser");
9678
+ if (missing.length > 0) {
9679
+ writeErr(`cdp install: missing required option(s) ${missing.join(", ")} in non-interactive mode.
9680
+ `);
9681
+ writeErr("Run `cdp install` in a terminal to be prompted, or pass the option(s) explicitly.\n\n");
9682
+ writeErr(`${installHelpText()}
9683
+ `);
9684
+ return 1;
9685
+ }
9686
+ if (port === undefined)
9687
+ port = defaultPortFor(browser);
9688
+ }
9689
+ if (harness === undefined || browser === undefined || port === undefined) {
9690
+ writeErr(`cdp install: internal error resolving options
9691
+ `);
9692
+ return 1;
9693
+ }
9694
+ const name = args.name ?? DEFAULT_NAME;
9695
+ const writeAlias = args.alias !== false;
9696
+ const rcPath = io.rcPath ?? detectShellRc();
9697
+ const resolvedAliasName = args.aliasName ?? defaultAliasName(browser);
9698
+ const line = aliasLine({ choice: browser, port, aliasName: args.aliasName });
9699
+ if (!args.yes && isTTY) {
9700
+ const plan = [
9701
+ "",
9702
+ "About to:",
9703
+ ` - Register MCP server "${name}" with ${harness} (${harnessTargetDesc(harness, io)})`,
9704
+ writeAlias ? ` - Append a shell alias to ${rcPath}:
9705
+ ${line}` : " - (skipping shell alias: --no-alias)",
9706
+ ""
9707
+ ].join(`
9708
+ `);
9709
+ write(`${plan}
9710
+ `);
9711
+ const ans = (await ask("Proceed? [y/N] ")).trim();
9712
+ if (!/^y(es)?$/i.test(ans)) {
9713
+ write(`Aborted. Nothing was written.
9714
+ `);
9715
+ return 0;
9716
+ }
9717
+ }
9718
+ let harnessResult;
9719
+ try {
9720
+ harnessResult = await installMcpServer(harness, name, composeServerConfig(browser, port), {
9721
+ configPath: io.configPath,
9722
+ homeDir: io.homeDir
9723
+ });
9724
+ } catch (err) {
9725
+ writeErr(`cdp install: could not register the MCP server with ${harness}: ${err.message}
9726
+ `);
9727
+ return 1;
9728
+ }
9729
+ let rcResult;
9730
+ let browserFound = true;
9731
+ if (writeAlias) {
9732
+ rcResult = await appendAliasToRc(rcPath, line);
9733
+ browserFound = detectBrowser(browser).found;
9734
+ if (!browserFound) {
9735
+ writeErr(`
9736
+ Warning: could not find the ${browser} binary at its expected location. The alias was ` + `written anyway, but \`${resolvedAliasName}\` will not launch until the binary is found.
9737
+ ` + ` Fix: set ${binEnvVar(browser)}=/path/to/${browser} and re-run \`cdp install\`, or edit the
9738
+ ` + ` alias line in ${rcPath} to point at the real binary.
9739
+ `);
9740
+ }
9741
+ }
9742
+ const summary = [
9743
+ "",
9744
+ "cdp-toolkit install complete.",
9745
+ "",
9746
+ `Harness: ${harness}`,
9747
+ ` server name: ${name}`,
9748
+ harnessResult.path ? ` config: ${harnessResult.path}` : ` registered: ${harnessTargetDesc(harness, io)}`,
9749
+ ` method: ${harnessResult.method}`,
9750
+ ` action: ${harnessResult.action}${harnessResult.detail ? ` (${harnessResult.detail})` : ""}`
9751
+ ];
9752
+ if (writeAlias && rcResult) {
9753
+ summary.push("", `Browser alias: ${resolvedAliasName} -> ${rcResult.rcPath}`, ` action: ${rcResult.action}`, ` line: ${line}`);
9754
+ if (!browserFound)
9755
+ summary.push(` note: ${browser} binary not found yet (see warning above)`);
9756
+ } else {
9757
+ summary.push("", "Browser alias: skipped (--no-alias).");
9758
+ }
9759
+ summary.push("", "Next steps:");
9760
+ if (writeAlias && rcResult) {
9761
+ summary.push(` 1. Load the alias into your shell: source ${rcResult.rcPath} (or open a new terminal)`, ` 2. Launch the browser with the debug port open: ${resolvedAliasName}`, ` 3. Restart / reconnect ${harness} so it picks up the "${name}" MCP server.`);
9762
+ if (browser === "firefox") {
9763
+ summary.push("", "Firefox note: the alias includes --marionette, which cdp-toolkit needs for its orphan", "auto-recovery path — don't remove it.");
9764
+ }
9765
+ } else {
9766
+ summary.push(` 1. Restart / reconnect ${harness} so it picks up the "${name}" MCP server.`);
9767
+ }
9768
+ summary.push("");
9769
+ write(`${summary.join(`
9770
+ `)}
9771
+ `);
9772
+ return 0;
9773
+ }
9774
+
8889
9775
  // src/cli.ts
8890
9776
  var USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
8891
9777
 
@@ -9034,6 +9920,9 @@ function printToolHelp(name) {
9034
9920
  }
9035
9921
  async function main() {
9036
9922
  const rawArgv = process.argv.slice(2);
9923
+ if (rawArgv[0] === "install") {
9924
+ return await runInstaller(rawArgv.slice(1));
9925
+ }
9037
9926
  const { kind: browser, endpoint } = resolveBackend(rawArgv);
9038
9927
  const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
9039
9928
  if (parsed.help) {