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/CHANGELOG.md +20 -0
- package/README.md +29 -7
- package/dist/cli.js +902 -13
- package/dist/mcp.js +340 -14
- package/package.json +2 -1
- package/src/bidi/driver.ts +190 -28
- package/src/bidi/marionette.ts +276 -0
- package/src/bidi/session-lease.ts +265 -0
- package/src/cli.ts +10 -0
- package/src/install/browser.ts +211 -0
- package/src/install/harness.ts +224 -0
- package/src/install/wizard.ts +412 -0
- package/src/mcp.ts +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firefox BiDi SESSION-slot lease: cross-process coordination for the ONE
|
|
3
|
+
* WebDriver-BiDi session a single Firefox instance will serve.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Firefox admits exactly one active WebDriver-BiDi session per
|
|
6
|
+
* browser. When two cdp-toolkit MCP-server processes ATTACH to the same
|
|
7
|
+
* user-launched Firefox endpoint (e.g. ws://127.0.0.1:9223/session), they both
|
|
8
|
+
* want that one slot. Today the second process calls session.new and gets a
|
|
9
|
+
* hard "Maximum number of active sessions" with no way to tell a LIVE holder
|
|
10
|
+
* (another agent legitimately driving the browser) from a DEAD one (a crashed
|
|
11
|
+
* process that left the slot occupied). This module makes that distinction on
|
|
12
|
+
* disk, so a live holder is respected and a dead holder's orphan is reclaimed.
|
|
13
|
+
*
|
|
14
|
+
* WHY A FILE, AND WHY KEYED BY ENDPOINT. Same reasoning as the tab leases in
|
|
15
|
+
* ../leases.ts: two MCP servers are two processes, so in-process state would
|
|
16
|
+
* guard the wrong boundary. The tab lease is keyed by (backend, targetId)
|
|
17
|
+
* because it owns a tab; this lease is keyed by the BiDi ws ENDPOINT string
|
|
18
|
+
* because it owns the browser's single session slot, which is a property of the
|
|
19
|
+
* endpoint, not of any tab. One lock file per endpoint.
|
|
20
|
+
*
|
|
21
|
+
* WHY NO NONCE/TOKEN. A tab lease mints a nonce because a caller passes a token
|
|
22
|
+
* back and a reclamation must invalidate the superseded one. Nobody passes a
|
|
23
|
+
* session token back here: the slot is owned by whichever process currently
|
|
24
|
+
* holds the live BiDi connection and is released on dispose. Ownership is
|
|
25
|
+
* therefore identified by (pid, createdAt) of the record we wrote, which is
|
|
26
|
+
* enough to keep release from unlinking a record another process created after
|
|
27
|
+
* stealing the slot from us.
|
|
28
|
+
*
|
|
29
|
+
* WHY STALENESS IS PURE PID-LIVENESS, WITH NO TTL. A long-lived MCP server can
|
|
30
|
+
* legitimately hold the slot for hours, so a time-based expiry would reap a
|
|
31
|
+
* healthy holder. The only thing that makes a holder illegitimate is that its
|
|
32
|
+
* process is gone. So the sole staleness test is isPidAlive (reused from
|
|
33
|
+
* ../leases.ts): pid alive -> legitimate holder; pid dead -> orphan to steal.
|
|
34
|
+
*
|
|
35
|
+
* ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
|
|
36
|
+
* ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
|
|
37
|
+
* against a racing process for free, with no registry lock.
|
|
38
|
+
*/
|
|
39
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { isPidAlive, leaseDir } from "../leases.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The on-disk record for a held session slot. Keyed by endpoint (one file per
|
|
45
|
+
* endpoint). No nonce: see the header. marionettePort is carried so a stealer
|
|
46
|
+
* can surface which Marionette port an orphaned session was bound to when it
|
|
47
|
+
* has to force-clear it.
|
|
48
|
+
*/
|
|
49
|
+
export interface SessionSlotRecord {
|
|
50
|
+
endpoint: string;
|
|
51
|
+
/** Owning MCP server process. The one field staleness turns on. */
|
|
52
|
+
pid: number;
|
|
53
|
+
/** Caller-supplied agent label, surfaced in busy/steal reporting. */
|
|
54
|
+
label: string;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
/** Optional Marionette control port bound to this Firefox, when known. */
|
|
57
|
+
marionettePort?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a holder keeps so it can release ITS OWN record and never another
|
|
62
|
+
* process's. endpoint locates the file; (pid, createdAt) identify our record.
|
|
63
|
+
*/
|
|
64
|
+
export interface SessionSlotHandle {
|
|
65
|
+
endpoint: string;
|
|
66
|
+
pid: number;
|
|
67
|
+
createdAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AcquireResult {
|
|
71
|
+
handle: SessionSlotHandle;
|
|
72
|
+
/**
|
|
73
|
+
* Set only when we STOLE a dead holder's slot. Its presence tells the caller
|
|
74
|
+
* that Firefox may still believe an orphaned BiDi session is active, so the
|
|
75
|
+
* caller must force-clear that session (e.g. via Marionette) before it calls
|
|
76
|
+
* session.new, or Firefox will still refuse with "Maximum number of active
|
|
77
|
+
* sessions". Absent on a clean first acquire.
|
|
78
|
+
*/
|
|
79
|
+
staleHolder?: SessionSlotRecord;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AcquireOptions {
|
|
83
|
+
label: string;
|
|
84
|
+
marionettePort?: number;
|
|
85
|
+
/** How long to keep polling a LIVE holder before giving up. Default sessionWaitMs(). */
|
|
86
|
+
waitMs?: number;
|
|
87
|
+
/** Injectable clock for the record's createdAt, so tests are deterministic. */
|
|
88
|
+
now?: number;
|
|
89
|
+
/** Poll interval while waiting on a live holder. Default DEFAULT_POLL_MS. */
|
|
90
|
+
pollMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Env-overridable wait ceiling, in ms. */
|
|
94
|
+
const DEFAULT_SESSION_WAIT_MS = 10_000;
|
|
95
|
+
/** Poll cadence while a live holder is blocking us. */
|
|
96
|
+
const DEFAULT_POLL_MS = 250;
|
|
97
|
+
|
|
98
|
+
/** Same substitution rule as ../leases.ts safeId, applied to the endpoint URL. */
|
|
99
|
+
function safeEndpoint(endpoint: string): string {
|
|
100
|
+
return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One lock file per endpoint, a sibling of the tab lease files in ARTIFACT_DIR. */
|
|
104
|
+
export function sessionSlotFile(endpoint: string): string {
|
|
105
|
+
return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
|
|
109
|
+
* that is not a finite number > 0 falls back to the default. */
|
|
110
|
+
export function sessionWaitMs(): number {
|
|
111
|
+
const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
|
|
112
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Thrown when the slot is held by a LIVE process and did not free up within the
|
|
117
|
+
* wait window (or the attempt cap was hit). Names the live holder so the caller
|
|
118
|
+
* can decide to wait and retry or point this server at a different browser.
|
|
119
|
+
*/
|
|
120
|
+
export class SessionSlotBusyError extends Error {
|
|
121
|
+
readonly endpoint: string;
|
|
122
|
+
readonly holderPid?: number;
|
|
123
|
+
readonly holderLabel?: string;
|
|
124
|
+
constructor(endpoint: string, holder?: SessionSlotRecord) {
|
|
125
|
+
const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
|
|
126
|
+
super(
|
|
127
|
+
`Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` +
|
|
128
|
+
`Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` +
|
|
129
|
+
`by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` +
|
|
130
|
+
`this server at a different endpoint/browser.`,
|
|
131
|
+
);
|
|
132
|
+
this.name = "SessionSlotBusyError";
|
|
133
|
+
this.endpoint = endpoint;
|
|
134
|
+
if (holder) {
|
|
135
|
+
this.holderPid = holder.pid;
|
|
136
|
+
this.holderLabel = holder.label;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the slot record, or undefined if the slot is genuinely free.
|
|
143
|
+
*
|
|
144
|
+
* Mirrors ../leases.ts readLease's fail-closed contract: ENOENT is "free" and
|
|
145
|
+
* returns undefined; any OTHER errno (EACCES, EIO, EISDIR, EMFILE...) is
|
|
146
|
+
* rethrown rather than reported as absent, because reporting "free" for a file
|
|
147
|
+
* that merely could not be read would admit a second process to a slot that is
|
|
148
|
+
* very much held. A record that reads but does not PARSE is treated as absent,
|
|
149
|
+
* deliberately: a corrupt record can never become readable, and failing closed
|
|
150
|
+
* on it would brick the endpoint with no in-product recovery, whereas an errno
|
|
151
|
+
* is transient.
|
|
152
|
+
*/
|
|
153
|
+
export async function readSessionSlot(endpoint: string): Promise<SessionSlotRecord | undefined> {
|
|
154
|
+
let raw: string;
|
|
155
|
+
try {
|
|
156
|
+
raw = await readFile(sessionSlotFile(endpoint), "utf8");
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; // genuinely free
|
|
159
|
+
throw err; // unreadable is not absent: fail closed
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const rec = JSON.parse(raw) as SessionSlotRecord;
|
|
163
|
+
if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
|
|
164
|
+
return rec;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* unparseable: fall through to undefined, see the doc comment above */
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Take the endpoint's session slot, coordinating with any other process.
|
|
174
|
+
*
|
|
175
|
+
* Returns { handle } on a clean acquire. Returns { handle, staleHolder } when
|
|
176
|
+
* the previous holder's process was dead and we stole its orphaned slot (the
|
|
177
|
+
* caller must then force-clear Firefox's orphaned session before session.new).
|
|
178
|
+
* Throws SessionSlotBusyError when a LIVE holder does not free the slot inside
|
|
179
|
+
* the wait window.
|
|
180
|
+
*/
|
|
181
|
+
export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions): Promise<AcquireResult> {
|
|
182
|
+
const waitMs = opts.waitMs ?? sessionWaitMs();
|
|
183
|
+
const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
|
|
184
|
+
// opts.now stamps OUR record deterministically for tests; the wait deadline
|
|
185
|
+
// uses real time (with real sleeps) so a small waitMs actually times out.
|
|
186
|
+
const createdAt = opts.now ?? Date.now();
|
|
187
|
+
const file = sessionSlotFile(endpoint);
|
|
188
|
+
|
|
189
|
+
const record: SessionSlotRecord = {
|
|
190
|
+
endpoint,
|
|
191
|
+
pid: process.pid,
|
|
192
|
+
label: opts.label,
|
|
193
|
+
createdAt,
|
|
194
|
+
};
|
|
195
|
+
if (opts.marionettePort !== undefined) record.marionettePort = opts.marionettePort;
|
|
196
|
+
const handle: SessionSlotHandle = { endpoint, pid: process.pid, createdAt };
|
|
197
|
+
const payload = JSON.stringify(record);
|
|
198
|
+
|
|
199
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
200
|
+
|
|
201
|
+
const deadline = Date.now() + waitMs;
|
|
202
|
+
// Bounds the immediate-retry paths (released-mid-race, steal-raced) so a
|
|
203
|
+
// pathological create/release partner can never spin us forever. The live
|
|
204
|
+
// holder is bounded by `deadline` instead; this cap is generous relative to
|
|
205
|
+
// it so it only bites on pathology, never on ordinary waiting.
|
|
206
|
+
const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
|
|
207
|
+
|
|
208
|
+
let attempts = 0;
|
|
209
|
+
let lastLiveHolder: SessionSlotRecord | undefined;
|
|
210
|
+
|
|
211
|
+
while (true) {
|
|
212
|
+
if (attempts >= maxAttempts) throw new SessionSlotBusyError(endpoint, lastLiveHolder);
|
|
213
|
+
attempts++;
|
|
214
|
+
|
|
215
|
+
// 1. Try to take the slot outright.
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(file, payload, { flag: "wx" });
|
|
218
|
+
return { handle };
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. Occupied. Read the holder to classify it.
|
|
224
|
+
const holder = await readSessionSlot(endpoint);
|
|
225
|
+
if (!holder) continue; // released between our create and our read: retry immediately
|
|
226
|
+
|
|
227
|
+
if (!isPidAlive(holder.pid)) {
|
|
228
|
+
// 3. DEAD holder: orphan. Steal it — unlink, then re-create OUR record.
|
|
229
|
+
await unlink(file).catch(() => undefined);
|
|
230
|
+
try {
|
|
231
|
+
await writeFile(file, payload, { flag: "wx" });
|
|
232
|
+
return { handle, staleHolder: holder };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
235
|
+
continue; // steal raced another process: retry immediately
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
|
|
240
|
+
lastLiveHolder = holder;
|
|
241
|
+
const remaining = deadline - Date.now();
|
|
242
|
+
if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
|
|
243
|
+
await sleep(Math.min(pollMs, remaining));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Release the slot, but ONLY if the record on disk is still the one we wrote.
|
|
249
|
+
*
|
|
250
|
+
* A record is ours iff its pid is this process AND its createdAt matches the
|
|
251
|
+
* handle. That guard is what stops us from unlinking a record another process
|
|
252
|
+
* created after stealing the slot from us (dead-pid steal), which would hand
|
|
253
|
+
* two processes the slot at once. Idempotent: a missing record is released:false.
|
|
254
|
+
*/
|
|
255
|
+
export async function releaseSessionSlot(handle: SessionSlotHandle): Promise<{ released: boolean }> {
|
|
256
|
+
const current = await readSessionSlot(handle.endpoint);
|
|
257
|
+
if (!current) return { released: false };
|
|
258
|
+
if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { released: false };
|
|
259
|
+
await unlink(sessionSlotFile(handle.endpoint)).catch(() => undefined); // swallow release races
|
|
260
|
+
return { released: true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sleep(ms: number): Promise<void> {
|
|
264
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
265
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { toolAvailability } from "./capabilities.ts";
|
|
|
28
28
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
29
29
|
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
30
30
|
import { MANIFEST } from "./manifest.ts";
|
|
31
|
+
import { runInstaller } from "./install/wizard.ts";
|
|
31
32
|
|
|
32
33
|
const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
33
34
|
|
|
@@ -210,6 +211,15 @@ function printToolHelp(name: ToolName): void {
|
|
|
210
211
|
|
|
211
212
|
async function main(): Promise<number> {
|
|
212
213
|
const rawArgv = process.argv.slice(2);
|
|
214
|
+
|
|
215
|
+
// `install` is a CLI-ONLY subcommand (the `cdp install` wizard), not an MCP tool: it is not in
|
|
216
|
+
// TOOLS/MANIFEST and never appears in --list or tools/list. Dispatch it FIRST — before --browser
|
|
217
|
+
// resolution, --help, and tool dispatch — so `cdp install --help` reaches the installer's own
|
|
218
|
+
// help rather than the global USAGE, and so nothing below can reinterpret its flags.
|
|
219
|
+
if (rawArgv[0] === "install") {
|
|
220
|
+
return await runInstaller(rawArgv.slice(1));
|
|
221
|
+
}
|
|
222
|
+
|
|
213
223
|
const { kind: browser, endpoint } = resolveBackend(rawArgv);
|
|
214
224
|
const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
|
|
215
225
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `cdp install`: binary detection, debug-launch alias generation, and an
|
|
3
|
+
* idempotent shell-rc append. The other half (harness/MCP config) lives in a sibling module and
|
|
4
|
+
* is not this file's concern — this file only knows about browsers and shell rc files.
|
|
5
|
+
*
|
|
6
|
+
* Zero runtime dependencies beyond node:fs, node:fs/promises, node:os, node:path.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { delimiter, dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export type BrowserChoice = "arc" | "chrome" | "firefox";
|
|
14
|
+
|
|
15
|
+
export interface BrowserInfo {
|
|
16
|
+
choice: BrowserChoice;
|
|
17
|
+
/**
|
|
18
|
+
* The binary path or command name to launch. When a PATH-relative name (e.g. "google-chrome")
|
|
19
|
+
* is the chosen candidate, this is returned UNRESOLVED — as the bare name, not an absolute path
|
|
20
|
+
* scanned off today's PATH — so the alias stays portable and lets the shell re-resolve it against
|
|
21
|
+
* PATH at invocation time rather than pinning it to wherever it happened to be found right now.
|
|
22
|
+
*/
|
|
23
|
+
binary: string;
|
|
24
|
+
found: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AliasOptions {
|
|
28
|
+
choice: BrowserChoice;
|
|
29
|
+
port: number;
|
|
30
|
+
aliasName?: string;
|
|
31
|
+
profileDir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RcAppendResult {
|
|
35
|
+
rcPath: string;
|
|
36
|
+
action: "created-block" | "replaced-block" | "unchanged";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ------------------------------- binary detection ------------------------------- */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-choice candidate lists, built fresh on every call so env var overrides (CHROME_BIN /
|
|
43
|
+
* ARC_BIN / FIREFOX_BIN) are read live rather than captured once at module load — important for
|
|
44
|
+
* tests that set/restore these env vars around a single call.
|
|
45
|
+
*
|
|
46
|
+
* macOS-first, Linux fallback. Order mirrors the spec: env override, then the macOS .app bundle
|
|
47
|
+
* path, then PATH-relative command names. Arc is macOS-mostly, so found=false on Linux (nothing
|
|
48
|
+
* in this list resolves there) is expected, not a bug.
|
|
49
|
+
*/
|
|
50
|
+
const CANDIDATE_BUILDERS: Record<BrowserChoice, () => Array<string | undefined>> = {
|
|
51
|
+
chrome: () => [
|
|
52
|
+
process.env.CHROME_BIN,
|
|
53
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
54
|
+
"google-chrome",
|
|
55
|
+
"google-chrome-stable",
|
|
56
|
+
"chromium",
|
|
57
|
+
],
|
|
58
|
+
arc: () => [process.env.ARC_BIN, "/Applications/Arc.app/Contents/MacOS/Arc", "arc"],
|
|
59
|
+
firefox: () => [
|
|
60
|
+
process.env.FIREFOX_BIN,
|
|
61
|
+
"/Applications/Firefox.app/Contents/MacOS/firefox",
|
|
62
|
+
"firefox",
|
|
63
|
+
"/usr/bin/firefox",
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Scan $PATH for `cmd`. Used only to decide `found` for a bare candidate — never to rewrite it. */
|
|
68
|
+
function existsOnPath(cmd: string): boolean {
|
|
69
|
+
const pathEnv = process.env.PATH;
|
|
70
|
+
if (!pathEnv) return false;
|
|
71
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
72
|
+
if (!dir) continue;
|
|
73
|
+
if (existsSync(join(dir, cmd))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Mirrors the resolveBinary logic in src/bidi/launch.ts: an ABSOLUTE candidate counts only if it
|
|
80
|
+
* exists on disk (otherwise a hardcoded macOS path would shadow a real PATH fallback on Linux and
|
|
81
|
+
* yield a not-found result at spawn time); a bare PATH name is returned as-is, unresolved, for the
|
|
82
|
+
* shell to resolve — but IS checked against $PATH here purely to set an accurate `found` flag.
|
|
83
|
+
*/
|
|
84
|
+
function resolveCandidate(candidates: Array<string | undefined>): { binary: string; found: boolean } {
|
|
85
|
+
let lastDefined = "";
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (!c) continue;
|
|
88
|
+
lastDefined = c;
|
|
89
|
+
if (c.startsWith("/")) {
|
|
90
|
+
if (existsSync(c)) return { binary: c, found: true };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (existsOnPath(c)) return { binary: c, found: true };
|
|
94
|
+
}
|
|
95
|
+
return { binary: lastDefined, found: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function detectBrowser(choice: BrowserChoice): BrowserInfo {
|
|
99
|
+
const { binary, found } = resolveCandidate(CANDIDATE_BUILDERS[choice]());
|
|
100
|
+
return { choice, binary, found };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ------------------------------- alias generation ------------------------------- */
|
|
104
|
+
|
|
105
|
+
function defaultProfileDir(choice: BrowserChoice): string {
|
|
106
|
+
// Literal $HOME (not resolved via os.homedir()) so it survives into the alias's stored text and
|
|
107
|
+
// expands fresh every time the alias runs — see the two-phase alias substitution note below.
|
|
108
|
+
return `$HOME/.cdp-toolkit/${choice}-profile`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function defaultAliasName(choice: BrowserChoice): string {
|
|
112
|
+
return `cdp-${choice}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The launch flags only (no binary). Chrome and Arc are both Chromium-based and Arc accepts
|
|
117
|
+
* Chrome's flags, so they share a shape: --remote-debugging-port plus a DEDICATED
|
|
118
|
+
* --user-data-dir. The dedicated profile is required, not cosmetic — Chrome silently ignores
|
|
119
|
+
* --remote-debugging-port when it hands off to an already-running instance on the default
|
|
120
|
+
* profile, so the port never opens and there is no error, just a hung attach later.
|
|
121
|
+
*
|
|
122
|
+
* Firefox's --marionette flag is likewise required, not optional: cdp-toolkit's Firefox
|
|
123
|
+
* orphan-recovery path (src/bidi/launch.ts's header comment) uses the Marionette side channel to
|
|
124
|
+
* recover a dropped BiDi connection, and that recovery cannot fire without it.
|
|
125
|
+
*/
|
|
126
|
+
export function browserLaunchArgs(o: AliasOptions): string[] {
|
|
127
|
+
const profileDir = o.profileDir ?? defaultProfileDir(o.choice);
|
|
128
|
+
if (o.choice === "firefox") {
|
|
129
|
+
return ["--remote-debugging-port", String(o.port), "--marionette", "--no-remote", "--profile", profileDir];
|
|
130
|
+
}
|
|
131
|
+
return [`--remote-debugging-port=${o.port}`, `--user-data-dir=${profileDir}`];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Quote only if needed: wrapping a plain token in quotes is harmless but unquoted-with-a-space breaks word splitting. */
|
|
135
|
+
function shellQuoteIfSpaced(token: string): string {
|
|
136
|
+
return /\s/.test(token) ? `"${token}"` : token;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A single `alias name='<binary> <flags>'` line. The whole value is wrapped in single quotes (so
|
|
141
|
+
* it is stored literally, unexpanded, when the alias is DEFINED); the binary is additionally
|
|
142
|
+
* double-quoted when it contains a space (the macOS "Google Chrome.app" / future spaced paths),
|
|
143
|
+
* because shell alias substitution is a two-phase process — text substitution first, then a fresh
|
|
144
|
+
* parse of the substituted text — so the inner double quotes and the bare $HOME in the profile
|
|
145
|
+
* dir both take effect correctly at every INVOCATION of the alias, not at definition time.
|
|
146
|
+
*/
|
|
147
|
+
export function aliasLine(o: AliasOptions): string {
|
|
148
|
+
const name = o.aliasName ?? defaultAliasName(o.choice);
|
|
149
|
+
const { binary } = detectBrowser(o.choice);
|
|
150
|
+
const parts = [shellQuoteIfSpaced(binary), ...browserLaunchArgs(o)];
|
|
151
|
+
return `alias ${name}='${parts.join(" ")}'`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ------------------------------- shell rc detection ------------------------------- */
|
|
155
|
+
|
|
156
|
+
export function detectShellRc(): string {
|
|
157
|
+
const shell = process.env.SHELL ?? "";
|
|
158
|
+
if (shell.endsWith("zsh")) return join(homedir(), ".zshrc");
|
|
159
|
+
if (shell.endsWith("bash")) return join(homedir(), ".bashrc");
|
|
160
|
+
return join(homedir(), process.platform === "darwin" ? ".zshrc" : ".bashrc");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------- idempotent rc append ------------------------------- */
|
|
164
|
+
|
|
165
|
+
const MARKER_START = "# >>> cdp-toolkit alias >>>";
|
|
166
|
+
const MARKER_END = "# <<< cdp-toolkit alias <<<";
|
|
167
|
+
|
|
168
|
+
function ensureTrailingNewline(s: string): string {
|
|
169
|
+
return s.endsWith("\n") ? s : `${s}\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Idempotently write ONE marker block into rcPath, replacing its body in place on re-runs rather
|
|
174
|
+
* than accumulating duplicates. The wizard owns composing aliasBlockBody (e.g. joining several
|
|
175
|
+
* browsers' alias lines with "\n") — this function only manages the single marker block, so
|
|
176
|
+
* calling it again with a fuller body updates the same block rather than adding a second one.
|
|
177
|
+
*/
|
|
178
|
+
export async function appendAliasToRc(rcPath: string, aliasBlockBody: string): Promise<RcAppendResult> {
|
|
179
|
+
let existing = "";
|
|
180
|
+
try {
|
|
181
|
+
existing = await readFile(rcPath, "utf8");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const block = `${MARKER_START}\n${aliasBlockBody}\n${MARKER_END}`;
|
|
187
|
+
|
|
188
|
+
const startIdx = existing.indexOf(MARKER_START);
|
|
189
|
+
const endIdx = existing.indexOf(MARKER_END);
|
|
190
|
+
|
|
191
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
192
|
+
const endOfEndLine = existing.indexOf("\n", endIdx);
|
|
193
|
+
const before = existing.slice(0, startIdx);
|
|
194
|
+
const after = endOfEndLine === -1 ? "" : existing.slice(endOfEndLine + 1);
|
|
195
|
+
const existingBlock = existing.slice(startIdx, endOfEndLine === -1 ? existing.length : endOfEndLine);
|
|
196
|
+
|
|
197
|
+
if (existingBlock === block) {
|
|
198
|
+
return { rcPath, action: "unchanged" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const next = ensureTrailingNewline(`${before}${block}\n${after}`);
|
|
202
|
+
await writeFile(rcPath, next, "utf8");
|
|
203
|
+
return { rcPath, action: "replaced-block" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await mkdir(dirname(rcPath), { recursive: true });
|
|
207
|
+
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
208
|
+
const next = `${existing}${sep}${block}\n`;
|
|
209
|
+
await writeFile(rcPath, next, "utf8");
|
|
210
|
+
return { rcPath, action: "created-block" };
|
|
211
|
+
}
|