opera-browser-cli 0.1.44 → 0.1.46

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.
@@ -1,18 +1,46 @@
1
1
  /**
2
2
  * HTTP client for the opera-browser-cli bridge + bridge lifecycle management.
3
+ *
4
+ * The lifecycle rules, in one place:
5
+ *
6
+ * - A process is only ever signalled once it has been positively identified
7
+ * as our bridge — by answering /health, or by matching a PID file entry
8
+ * recorded on this same boot. A recycled PID after a reboot must never be
9
+ * mistaken for ours.
10
+ * - A bridge running a different package version is unusable, however
11
+ * healthy it looks: it is serving pre-upgrade code from memory.
12
+ * - Exactly one process starts a bridge at a time (an exclusive lock), and
13
+ * if the port it wants is taken it moves to the next one.
14
+ * - A connection lost mid-command is recovered transparently, except for the
15
+ * expensive Opera AI tools, which are never silently replayed.
3
16
  */
4
17
  import { spawn } from "node:child_process";
5
- import { mkdirSync, openSync, readFileSync, existsSync } from "node:fs";
18
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
6
19
  import { join } from "node:path";
7
20
  import { homedir } from "node:os";
8
21
  import { request } from "node:http";
9
22
  import { AxiError } from "axi-sdk-js";
10
- import { resolveBridgeScript } from "./bridge.js";
23
+ import { resolveBridgeLauncher, } from "./bridge.js";
24
+ import { computeBootMinute, isOurBridge, isUsableBridge, parseHealth, sameBoot, } from "./identity.js";
25
+ import { getPackageVersion } from "./version.js";
11
26
  const STATE_DIR = join(homedir(), ".opera-browser-cli");
12
27
  const PID_FILE = join(STATE_DIR, "bridge.pid");
13
28
  const CONFIG_FILE = join(STATE_DIR, "config");
14
29
  const LOG_FILE = join(STATE_DIR, "bridge.log");
30
+ const LOCK_FILE = join(STATE_DIR, "bridge.lock");
15
31
  const DEFAULT_PORT = 9225;
32
+ /** How many consecutive ports to try before giving up. */
33
+ const PORT_SCAN_COUNT = 10;
34
+ /** Budget for a single bridge process to reach READY (Chrome launch is slow). */
35
+ const START_TIMEOUT_MS = 30_000;
36
+ /** A start lock older than this is assumed abandoned. */
37
+ const LOCK_STALE_MS = 60_000;
38
+ /** Grace period for a SIGTERMed bridge before escalating to SIGKILL. */
39
+ const STOP_GRACE_MS = 5_000;
40
+ /** Rotate the bridge log past this size so it cannot grow without bound. */
41
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
42
+ /** Lines of bridge.log to quote back when a startup fails. */
43
+ const LOG_TAIL_LINES = 20;
16
44
  export function getLogFile() {
17
45
  return LOG_FILE;
18
46
  }
@@ -80,6 +108,14 @@ function readPidFile() {
80
108
  return null;
81
109
  }
82
110
  }
111
+ function removePidFile() {
112
+ try {
113
+ unlinkSync(PID_FILE);
114
+ }
115
+ catch {
116
+ // Already gone — fine
117
+ }
118
+ }
83
119
  /** Read the bridge's per-instance auth token from the PID file, if present. */
84
120
  function readBridgeToken() {
85
121
  return readPidFile()?.token ?? null;
@@ -93,6 +129,37 @@ function isProcessAlive(pid) {
93
129
  return false;
94
130
  }
95
131
  }
132
+ /**
133
+ * Whether a PID file entry may be signalled.
134
+ *
135
+ * Requires the entry to record the boot it was written on, and that boot to be
136
+ * the current one. Entries without a boot stamp (pre-0.1.46) are only
137
+ * trustworthy when something has *also* identified the port as ours — see
138
+ * `resolveSignalablePid`.
139
+ */
140
+ function pidFileIsFromThisBoot(info) {
141
+ return (typeof info.bootMinute === "number" &&
142
+ sameBoot(info.bootMinute, computeBootMinute()));
143
+ }
144
+ /**
145
+ * Work out which PID, if any, it is safe to signal for the bridge on `port`.
146
+ *
147
+ * `health.pid` is authoritative — that process just told us who it is. Older
148
+ * bridges do not report a PID; for those we fall back to the PID file, but only
149
+ * when it names the same port, which means the file was written by whatever is
150
+ * answering there now.
151
+ */
152
+ function resolveSignalablePid(port, health) {
153
+ if (health.pid > 0)
154
+ return health.pid;
155
+ const info = readPidFile();
156
+ if (info && info.port === port && isProcessAlive(info.pid))
157
+ return info.pid;
158
+ return null;
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // HTTP
162
+ // ---------------------------------------------------------------------------
96
163
  function httpGet(port, path, timeoutMs = 2000, token) {
97
164
  return new Promise((resolve, reject) => {
98
165
  const req = request({
@@ -181,103 +248,456 @@ function httpPost(port, path, body, timeoutMs = 120_000, onLog, token) {
181
248
  req.end();
182
249
  });
183
250
  }
184
- async function isBridgeHealthy(port) {
251
+ function sleep(ms) {
252
+ return new Promise((r) => setTimeout(r, ms));
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // Discovery
256
+ // ---------------------------------------------------------------------------
257
+ /** The ports a bridge may live on, in preference order. */
258
+ export function candidatePorts() {
259
+ const base = Number.parseInt(process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), 10);
260
+ const start = Number.isFinite(base) ? base : DEFAULT_PORT;
261
+ return Array.from({ length: PORT_SCAN_COUNT }, (_, i) => start + i);
262
+ }
263
+ /**
264
+ * Ask what is listening on a port.
265
+ *
266
+ * Returns the identity if it is one of our bridges (of any version), and null
267
+ * for everything else: nothing listening, a foreign server, or a response we
268
+ * cannot parse. A foreign server is deliberately indistinguishable from an
269
+ * empty port here — the caller handles both the same way, by moving on and
270
+ * letting the bridge's own EADDRINUSE handling sort out the collision.
271
+ */
272
+ async function probeHealth(port) {
185
273
  try {
186
- const resp = await httpGet(port, "/health", 2000);
187
- const data = JSON.parse(resp);
188
- return data.status === "ok";
274
+ return parseHealth(await httpGet(port, "/health", 2000));
189
275
  }
190
276
  catch {
191
- return false;
277
+ return null;
192
278
  }
193
279
  }
280
+ async function probeAll(ports) {
281
+ return Promise.all(ports.map(async (port) => ({ port, health: await probeHealth(port) })));
282
+ }
194
283
  /**
195
- * Check what is listening on a port.
196
- * Returns "ok" if it is our bridge, "conflict" if something else responded,
197
- * or "free" if nothing is listening.
284
+ * Find a bridge we can use, cleaning up any of our own that we cannot.
285
+ *
286
+ * Stale-version bridges are shut down rather than left running: they hold a
287
+ * port, they will never become usable, and leaving them behind is how a machine
288
+ * accumulates zombies across upgrades.
198
289
  */
199
- async function checkPortStatus(port) {
290
+ export async function findUsableBridge(ports) {
291
+ const version = getPackageVersion();
292
+ // Fast path: the port in the PID file is nearly always the answer, and
293
+ // checking it alone keeps the common case to a single round trip.
294
+ const preferred = readPidFile()?.port;
295
+ if (preferred !== undefined && ports.includes(preferred)) {
296
+ const health = await probeHealth(preferred);
297
+ if (isUsableBridge(health, version))
298
+ return preferred;
299
+ if (isOurBridge(health))
300
+ await shutdownBridgeOnPort(preferred, health);
301
+ }
302
+ const probes = await probeAll(ports.filter((p) => p !== preferred));
303
+ for (const { port, health } of probes) {
304
+ if (isUsableBridge(health, version))
305
+ return port;
306
+ }
307
+ for (const { port, health } of probes) {
308
+ if (isOurBridge(health))
309
+ await shutdownBridgeOnPort(port, health);
310
+ }
311
+ return null;
312
+ }
313
+ /** Poll for a bridge someone else is starting. */
314
+ async function waitForUsableBridge(ports, timeoutMs) {
315
+ const deadline = Date.now() + timeoutMs;
316
+ while (Date.now() < deadline) {
317
+ const port = await findUsableBridge(ports);
318
+ if (port !== null)
319
+ return port;
320
+ await sleep(250);
321
+ }
322
+ return null;
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // Shutdown
326
+ // ---------------------------------------------------------------------------
327
+ /** SIGTERM, wait, SIGKILL. Returns true if the process is gone afterwards. */
328
+ async function terminateProcess(pid) {
200
329
  try {
201
- const resp = await httpGet(port, "/health", 2000);
202
- const data = JSON.parse(resp);
203
- if (data.server === "opera-browser-cli") {
204
- return data.status === "ok" ? "ok" : "free";
205
- }
206
- return "conflict";
330
+ process.kill(pid, "SIGTERM");
331
+ }
332
+ catch {
333
+ return { gone: true, forced: false };
334
+ }
335
+ const deadline = Date.now() + STOP_GRACE_MS;
336
+ while (Date.now() < deadline) {
337
+ if (!isProcessAlive(pid))
338
+ return { gone: true, forced: false };
339
+ await sleep(100);
340
+ }
341
+ try {
342
+ process.kill(pid, "SIGKILL");
207
343
  }
208
344
  catch {
209
- return "free";
345
+ return { gone: true, forced: true };
210
346
  }
347
+ await sleep(200);
348
+ return { gone: !isProcessAlive(pid), forced: true };
211
349
  }
212
- function sleep(ms) {
213
- return new Promise((r) => setTimeout(r, ms));
350
+ /** Shut down a bridge we have positively identified on `port`. */
351
+ async function shutdownBridgeOnPort(port, health) {
352
+ const pid = resolveSignalablePid(port, health);
353
+ if (pid === null)
354
+ return;
355
+ await terminateProcess(pid);
356
+ if (readPidFile()?.pid === pid)
357
+ removePidFile();
214
358
  }
215
- /**
216
- * Ensure the bridge is running, starting it if needed. Returns the port.
217
- */
218
- export async function ensureBridge() {
219
- const port = parseInt(process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), 10);
220
- // Check existing bridge via PID file (lenient: we trust our own PID file).
221
- const pidInfo = readPidFile();
222
- if (pidInfo && isProcessAlive(pidInfo.pid)) {
223
- if (await isBridgeHealthy(pidInfo.port)) {
224
- return pidInfo.port;
359
+ /** Shut down every bridge of ours across the candidate ports. */
360
+ async function shutdownOurBridges(ports) {
361
+ for (const { port, health } of await probeAll(ports)) {
362
+ if (isOurBridge(health))
363
+ await shutdownBridgeOnPort(port, health);
364
+ }
365
+ }
366
+ let holdingLock = false;
367
+ function readLock() {
368
+ try {
369
+ const data = JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
370
+ if (typeof data.pid !== "number" || typeof data.startedAt !== "number") {
371
+ return null;
225
372
  }
373
+ return { pid: data.pid, startedAt: data.startedAt };
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ }
379
+ function acquireStartLock() {
380
+ try {
381
+ mkdirSync(STATE_DIR, { recursive: true });
382
+ const fd = openSync(LOCK_FILE, "wx");
226
383
  try {
227
- process.kill(pidInfo.pid, "SIGTERM");
384
+ writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
228
385
  }
229
- catch {
230
- // Best effort — if shutdown fails, the startup poll below will time out.
386
+ finally {
387
+ closeSync(fd);
231
388
  }
389
+ holdingLock = true;
390
+ // Held only while we hold the lock, so an interrupted start still releases
391
+ // it and we never accumulate listeners across repeated acquisitions.
392
+ process.on("exit", releaseStartLock);
393
+ return true;
232
394
  }
233
- // Check for a foreign server already occupying the target port before spawning.
234
- const portStatus = await checkPortStatus(port);
235
- if (portStatus === "ok") {
236
- // A healthy bridge is already running (no PID file or stale PID).
237
- return port;
395
+ catch {
396
+ return false;
238
397
  }
239
- if (portStatus === "conflict") {
240
- throw new CdpError(`Port ${port} is in use by a different server (not opera-devtools-mcp). Stop it or choose a different port.`, "BRIDGE_NOT_READY", [
241
- `Stop the process on port ${port} and try again, or set OPERA_CLI_PORT to a different port number`,
242
- ]);
398
+ }
399
+ function releaseStartLock() {
400
+ if (!holdingLock)
401
+ return;
402
+ holdingLock = false;
403
+ process.off("exit", releaseStartLock);
404
+ try {
405
+ unlinkSync(LOCK_FILE);
243
406
  }
244
- // Start a new bridge
245
- const bridgeScript = resolveBridgeScript(import.meta.dirname);
246
- // Try .ts first (dev mode), fall back to .js (built)
247
- const script = existsSync(bridgeScript.replace(/\.js$/, ".ts"))
248
- ? bridgeScript.replace(/\.js$/, ".ts")
249
- : bridgeScript;
250
- const runner = script.endsWith(".ts") ? "tsx" : "node";
251
- // Pipe bridge stdout/stderr to ~/.opera-browser-cli/bridge.log so failures
252
- // are inspectable. Falls back to "ignore" if the file can't be opened.
253
- let stdio = "ignore";
407
+ catch {
408
+ // Someone else cleaned it up — fine
409
+ }
410
+ }
411
+ /** True when the lock is held by a dead process or has simply been there too long. */
412
+ function startLockIsStale() {
413
+ const lock = readLock();
414
+ if (lock === null)
415
+ return true; // unreadable or malformed
416
+ if (!isProcessAlive(lock.pid))
417
+ return true;
418
+ return Date.now() - lock.startedAt > LOCK_STALE_MS;
419
+ }
420
+ /**
421
+ * Remove an abandoned lock and take it.
422
+ *
423
+ * Two processes can both decide a lock is stale and both end up believing they
424
+ * hold it. That is tolerable: the loser's bridge fails with EADDRINUSE and
425
+ * retries the next port, which is exactly the path the port scan already
426
+ * handles. The lock removes the common case; the port scan is the real backstop.
427
+ */
428
+ function stealStartLock() {
429
+ try {
430
+ unlinkSync(LOCK_FILE);
431
+ }
432
+ catch {
433
+ // Already gone
434
+ }
435
+ return acquireStartLock();
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Logging
439
+ // ---------------------------------------------------------------------------
440
+ /** Rotate the bridge log now, whatever its size. Used by `doctor --fix`. */
441
+ export function rotateBridgeLog() {
442
+ try {
443
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
444
+ return true;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
450
+ function rotateLogIfLarge() {
451
+ try {
452
+ if (statSync(LOG_FILE).size < MAX_LOG_BYTES)
453
+ return;
454
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
455
+ }
456
+ catch {
457
+ // No log yet, or rotation is not possible — never block a start over it.
458
+ }
459
+ }
460
+ /** The tail of the bridge log, for quoting back when a start fails. */
461
+ function readLogTail(lines = LOG_TAIL_LINES) {
462
+ try {
463
+ const all = readFileSync(LOG_FILE, "utf-8").split("\n").filter(Boolean);
464
+ return all.slice(-lines).join("\n");
465
+ }
466
+ catch {
467
+ return "";
468
+ }
469
+ }
470
+ function openLogFd() {
254
471
  try {
255
472
  mkdirSync(STATE_DIR, { recursive: true });
256
- const logFd = openSync(LOG_FILE, "a");
257
- stdio = ["ignore", logFd, logFd];
473
+ rotateLogIfLarge();
474
+ return openSync(LOG_FILE, "a");
258
475
  }
259
476
  catch {
260
- // Log directory unwritable — bridge still runs, just no logs.
477
+ // Log directory unwritable — the bridge still runs, just without logs.
478
+ return null;
261
479
  }
262
- const child = spawn(runner === "tsx" ? "npx" : "node", runner === "tsx" ? ["tsx", script] : [script], {
263
- stdio,
480
+ }
481
+ /**
482
+ * Start one bridge process on one port and wait for its handshake.
483
+ *
484
+ * The bridge reports READY or FAILED on stdout, so a dead child is detected in
485
+ * milliseconds instead of costing the full startup budget. Its stderr goes to
486
+ * the log file, whose tail is folded into the failure detail.
487
+ */
488
+ async function spawnBridge(port) {
489
+ const launcher = resolveBridgeLauncher(import.meta.dirname);
490
+ if (!launcher.ok)
491
+ return { ok: false, reason: launcher.reason };
492
+ const logFd = openLogFd();
493
+ const child = spawn(launcher.command, launcher.args, {
494
+ stdio: ["ignore", "pipe", logFd ?? "ignore"],
264
495
  env: { ...process.env, OPERA_CLI_PORT: String(port) },
265
496
  detached: true,
266
497
  });
267
- child.unref();
268
- // Poll for health (max 30s — Chrome launch can be slow)
269
- const deadline = Date.now() + 30_000;
270
- while (Date.now() < deadline) {
271
- if (await isBridgeHealthy(port)) {
498
+ return new Promise((resolve) => {
499
+ let settled = false;
500
+ let buffer = "";
501
+ const finish = (outcome) => {
502
+ if (settled)
503
+ return;
504
+ settled = true;
505
+ clearTimeout(timer);
506
+ child.removeAllListeners("exit");
507
+ child.removeAllListeners("error");
508
+ child.stdout?.removeAllListeners("data");
509
+ // Release the pipe so this process can exit; the bridge writes nothing
510
+ // to stdout after the handshake and guards against EPIPE regardless.
511
+ child.stdout?.destroy();
512
+ child.unref();
513
+ if (logFd !== null) {
514
+ try {
515
+ closeSync(logFd);
516
+ }
517
+ catch {
518
+ // Already closed
519
+ }
520
+ }
521
+ resolve(outcome);
522
+ };
523
+ const timer = setTimeout(() => finish({ ok: false, reason: "timeout", detail: readLogTail() }), START_TIMEOUT_MS);
524
+ child.stdout?.setEncoding("utf-8");
525
+ child.stdout?.on("data", (chunk) => {
526
+ buffer += chunk;
527
+ let newline;
528
+ while ((newline = buffer.indexOf("\n")) !== -1) {
529
+ const line = buffer.slice(0, newline).trim();
530
+ buffer = buffer.slice(newline + 1);
531
+ if (line === "READY") {
532
+ finish({ ok: true });
533
+ return;
534
+ }
535
+ if (line.startsWith("FAILED ")) {
536
+ const rest = line.slice("FAILED ".length);
537
+ const spaceAt = rest.indexOf(" ");
538
+ finish({
539
+ ok: false,
540
+ reason: spaceAt === -1 ? rest : rest.slice(0, spaceAt),
541
+ detail: spaceAt === -1 ? undefined : rest.slice(spaceAt + 1),
542
+ });
543
+ return;
544
+ }
545
+ }
546
+ });
547
+ child.on("error", (error) => finish({ ok: false, reason: "spawn-failed", detail: error.message }));
548
+ child.on("exit", (code) => finish({
549
+ ok: false,
550
+ reason: code === 75 ? "port-in-use" : "exited",
551
+ detail: `bridge exited with code ${code}\n${readLogTail()}`,
552
+ }));
553
+ });
554
+ }
555
+ function startFailureError(outcome, ports) {
556
+ const detail = outcome.detail ? `\n${outcome.detail}` : "";
557
+ switch (outcome.reason) {
558
+ case "mcp-connect":
559
+ return new CdpError(`Bridge could not connect to opera-devtools-mcp.${detail}`, "BRIDGE_NOT_READY", [
560
+ "Check that opera-devtools-mcp is installed: `npx opera-devtools-mcp@latest --help`",
561
+ "For local dev: set OPERA_CLI_MCP_BIN to the linked binary",
562
+ "Run `opera-browser-cli logs` for the full bridge output",
563
+ ]);
564
+ case "state-dir-unwritable":
565
+ return new CdpError(`Bridge cannot write to its state directory (${outcome.detail ?? STATE_DIR}).`, "BRIDGE_NOT_READY", [
566
+ `Check ownership: \`ls -ld ${outcome.detail ?? STATE_DIR}\``,
567
+ `If it is root-owned from an earlier sudo run: \`sudo chown -R "$(whoami)" ${outcome.detail ?? STATE_DIR}\``,
568
+ ]);
569
+ case "tsx-not-installed":
570
+ return new CdpError("Bridge cannot run from TypeScript source — tsx is not installed.", "BRIDGE_NOT_READY", [
571
+ "Run `npm install` in the opera-browser-cli checkout",
572
+ "Or build first: `npm run build`",
573
+ ]);
574
+ case "bridge-not-built":
575
+ return new CdpError("Bridge entrypoint not found — the package looks unbuilt.", "BRIDGE_NOT_READY", ["Run `npm run build` in the opera-browser-cli checkout"]);
576
+ case "port-in-use":
577
+ return new CdpError(`Ports ${ports[0]}-${ports[ports.length - 1]} are all in use by other servers.`, "BRIDGE_NOT_READY", [
578
+ "Free one of those ports, or set OPERA_CLI_PORT to a different base port",
579
+ ]);
580
+ case "timeout":
581
+ return new CdpError(`Bridge did not become ready within ${START_TIMEOUT_MS / 1000}s.${detail}`, "BRIDGE_NOT_READY", [
582
+ "Run `opera-browser-cli logs` to see what the bridge was doing",
583
+ "Run `opera-browser-cli doctor` to check the configuration",
584
+ ]);
585
+ default:
586
+ return new CdpError(`Bridge failed to start.${detail}`, "BRIDGE_NOT_READY", [
587
+ "Run `opera-browser-cli logs` for the full bridge output",
588
+ "Run `opera-browser-cli doctor` to check the configuration",
589
+ ]);
590
+ }
591
+ }
592
+ /**
593
+ * Take the start lock and bring a bridge up, walking the port range.
594
+ *
595
+ * If another process holds the lock we wait for its bridge instead of racing
596
+ * it; only an abandoned lock is stolen.
597
+ */
598
+ async function startBridge(ports, attempt = 0) {
599
+ if (!acquireStartLock()) {
600
+ const port = await waitForUsableBridge(ports, START_TIMEOUT_MS);
601
+ if (port !== null)
272
602
  return port;
603
+ if (attempt >= 1 || !startLockIsStale() || !stealStartLock()) {
604
+ throw new CdpError("Timed out waiting for another opera-browser-cli process to start the bridge", "BRIDGE_NOT_READY", [
605
+ "Run `opera-browser-cli logs` to see what the other process was doing",
606
+ "Run `opera-browser-cli restart` to force a clean start",
607
+ ]);
273
608
  }
274
- await sleep(500);
609
+ return startBridge(ports, attempt + 1);
275
610
  }
276
- throw new CdpError("Bridge failed to start within 30s", "BRIDGE_NOT_READY", [
277
- "For local dev: set OPERA_CLI_MCP_BIN to the linked binary, e.g. OPERA_CLI_MCP_BIN=opera-devtools-mcp",
278
- "For published version: check that opera-devtools-mcp is installed: npx opera-devtools-mcp@latest --help",
279
- ]);
611
+ try {
612
+ let lastOutcome = { ok: false, reason: "port-in-use" };
613
+ for (const port of ports) {
614
+ lastOutcome = await spawnBridge(port);
615
+ if (lastOutcome.ok)
616
+ return port;
617
+ // Only a port collision is worth trying the next port for; anything else
618
+ // will fail the same way everywhere, so surface it immediately.
619
+ if (lastOutcome.reason !== "port-in-use")
620
+ break;
621
+ }
622
+ throw startFailureError(lastOutcome, ports);
623
+ }
624
+ finally {
625
+ releaseStartLock();
626
+ }
627
+ }
628
+ /**
629
+ * Ensure a bridge running our version is up. Returns the port it is on.
630
+ */
631
+ export async function ensureBridge(options = {}) {
632
+ const ports = candidatePorts();
633
+ if (options.forceRestart) {
634
+ await shutdownOurBridges(ports);
635
+ }
636
+ else {
637
+ const existing = await findUsableBridge(ports);
638
+ if (existing !== null)
639
+ return existing;
640
+ }
641
+ return startBridge(ports);
642
+ }
643
+ /**
644
+ * Stop the bridge.
645
+ *
646
+ * Looks past the PID file: if the file is missing or stale but a bridge of ours
647
+ * is answering on one of the candidate ports, that one is stopped too. Escalates
648
+ * to SIGKILL rather than reporting success against a process that ignored the
649
+ * signal, and always leaves the PID file cleaned up.
650
+ */
651
+ export async function stopBridge() {
652
+ const result = {
653
+ stopped: false,
654
+ stale: false,
655
+ forced: false,
656
+ pid: null,
657
+ port: null,
658
+ };
659
+ // Prefer a live, identified bridge — that is the one actually holding a port.
660
+ for (const { port, health } of await probeAll(candidatePorts())) {
661
+ if (!isOurBridge(health))
662
+ continue;
663
+ const pid = resolveSignalablePid(port, health);
664
+ if (pid === null)
665
+ continue;
666
+ const outcome = await terminateProcess(pid);
667
+ result.stopped ||= outcome.gone;
668
+ result.forced ||= outcome.forced;
669
+ result.pid ??= pid;
670
+ result.port ??= port;
671
+ }
672
+ const info = readPidFile();
673
+ if (info) {
674
+ if (!result.stopped) {
675
+ // Nothing answered. Only signal the recorded PID if the file is provably
676
+ // from this boot — otherwise the PID may belong to a stranger.
677
+ if (pidFileIsFromThisBoot(info) && isProcessAlive(info.pid)) {
678
+ const outcome = await terminateProcess(info.pid);
679
+ result.stopped = outcome.gone;
680
+ result.forced = outcome.forced;
681
+ result.pid = info.pid;
682
+ result.port = info.port;
683
+ }
684
+ else {
685
+ result.stale = true;
686
+ result.pid = info.pid;
687
+ result.port = info.port;
688
+ }
689
+ }
690
+ removePidFile();
691
+ }
692
+ return result;
693
+ }
694
+ /** Stop whatever is running and bring a fresh bridge up. Returns the port. */
695
+ export async function restartBridge() {
696
+ return ensureBridge({ forceRestart: true });
280
697
  }
698
+ // ---------------------------------------------------------------------------
699
+ // Tool calls
700
+ // ---------------------------------------------------------------------------
281
701
  const OPERA_AI_TIMEOUT = 1_200_000; // 20 minutes
282
702
  const OPERA_AI_TOOLS = new Set([
283
703
  "opera_chat",
@@ -286,33 +706,142 @@ const OPERA_AI_TOOLS = new Set([
286
706
  "opera_make",
287
707
  ]);
288
708
  /**
289
- * Call an MCP tool via the bridge. Returns the text result.
709
+ * Tools that must never be replayed after a dropped connection.
710
+ *
711
+ * All four Opera AI tools are long-running, billable, and may have already
712
+ * acted on the page before the bridge went away. A silent second run could
713
+ * double a booking as easily as it could double a bill.
290
714
  */
291
- export async function callTool(name, args = {}) {
292
- const port = await ensureBridge();
715
+ const NON_REPLAYABLE_TOOLS = OPERA_AI_TOOLS;
716
+ function errorMessageOf(error) {
717
+ return error instanceof Error ? error.message : String(error);
718
+ }
719
+ /** A dropped or rejected bridge connection, as opposed to a tool-level failure. */
720
+ function isTransportFailure(message) {
721
+ return (/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|MCP transport disconnected/i.test(message) || isAuthFailure(message));
722
+ }
723
+ /**
724
+ * The bridge's own 401. Matched exactly: page content and tool output routinely
725
+ * contain the word "unauthorized" and must not trigger a restart.
726
+ */
727
+ function isAuthFailure(message) {
728
+ return message.trim().toLowerCase() === "unauthorized";
729
+ }
730
+ /**
731
+ * A page-state race rather than a real failure: the DOM moved under us while
732
+ * the call was in flight. Common during navigation, and almost always gone by
733
+ * the time we ask again.
734
+ */
735
+ function isTransientPageFailure(message) {
736
+ return /detached|execution context was destroyed|cannot find context|no node with given id|target closed/i.test(message);
737
+ }
738
+ async function callToolOnce(name, args, options) {
739
+ const port = await ensureBridge(options);
293
740
  const isStreaming = OPERA_AI_TOOLS.has(name);
294
741
  const timeoutMs = isStreaming ? OPERA_AI_TIMEOUT : undefined;
295
742
  const onLog = isStreaming
296
743
  ? (msg) => process.stderr.write(msg + "\n")
297
744
  : undefined;
745
+ const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, readBridgeToken());
746
+ const data = JSON.parse(resp);
747
+ if (data.error)
748
+ throw new Error(data.error);
749
+ return data.result ?? "";
750
+ }
751
+ /**
752
+ * Call an MCP tool via the bridge. Returns the text result.
753
+ *
754
+ * A connection lost mid-call is recovered once: the bridge is restarted and the
755
+ * call replayed. The Opera AI tools are exempt from *that* recovery — they are
756
+ * reported instead, so the user decides whether to pay for a second run.
757
+ *
758
+ * A second, distinct failure is also repaired: the bridge answers, but the
759
+ * browser it was told to drive is unreachable (a dead attach URL, or a managed
760
+ * launch that never produced a browser). devtools-mcp reports that as a tool
761
+ * *result*, not an error, so it would otherwise look like success. We rebuild
762
+ * the bridge against the current target and retry once — safe for every tool,
763
+ * because nothing could have acted on a browser that was never reached.
764
+ */
765
+ export async function callTool(name, args = {}) {
766
+ let result;
298
767
  try {
299
- const token = readBridgeToken();
300
- const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, token);
301
- const data = JSON.parse(resp);
302
- if (data.error) {
303
- throw new Error(data.error);
768
+ result = await callToolOnce(name, args, {});
769
+ }
770
+ catch (error) {
771
+ return recoverFailedCall(name, args, error);
772
+ }
773
+ // The bridge answered, but the browser it was told to drive is unreachable
774
+ // (a dead attach URL, or a managed launch that never produced a browser).
775
+ // Rebuild the bridge against the current target and retry once — safe for
776
+ // every tool, because nothing could have acted on a browser that was never
777
+ // reached. A persistent failure is a real error, not a fake success.
778
+ if (isBrowserUnreachableResult(result)) {
779
+ try {
780
+ const recovered = await callToolOnce(name, args, { forceRestart: true });
781
+ if (!isBrowserUnreachableResult(recovered))
782
+ return recovered;
783
+ }
784
+ catch (retryError) {
785
+ return recoverFailedCall(name, args, retryError);
786
+ }
787
+ throw browserUnreachableError();
788
+ }
789
+ return result;
790
+ }
791
+ /**
792
+ * Handle an exception thrown by the bridge: recover what is worth recovering
793
+ * (transient page races, dropped transport), and map the rest to an error code.
794
+ */
795
+ async function recoverFailedCall(name, args, error) {
796
+ const message = errorMessageOf(error);
797
+ // A page-state race is worth one immediate retry against the same bridge —
798
+ // no restart, no user-visible failure.
799
+ if (isTransientPageFailure(message) && !NON_REPLAYABLE_TOOLS.has(name)) {
800
+ await sleep(250);
801
+ try {
802
+ return await callToolOnce(name, args, {});
803
+ }
804
+ catch (retryError) {
805
+ throw mapErrorMessage(errorMessageOf(retryError));
304
806
  }
305
- return data.result ?? "";
306
807
  }
307
- catch (err) {
308
- const message = err instanceof Error ? err.message : String(err);
808
+ if (!isTransportFailure(message))
309
809
  throw mapErrorMessage(message);
810
+ if (NON_REPLAYABLE_TOOLS.has(name)) {
811
+ throw new CdpError(`The bridge connection dropped while running ${name}, and the command was not retried automatically because it may already have taken effect.`, "BRIDGE_NOT_READY", [
812
+ "Re-run the command — the bridge restarts automatically",
813
+ "Run `opera-browser-cli logs` to see why the bridge dropped",
814
+ ]);
815
+ }
816
+ try {
817
+ return await callToolOnce(name, args, { forceRestart: true });
818
+ }
819
+ catch (retryError) {
820
+ throw mapErrorMessage(errorMessageOf(retryError));
310
821
  }
311
822
  }
823
+ /** devtools-mcp's "I have no browser to talk to" result text. */
824
+ function isBrowserUnreachableResult(result) {
825
+ return /could not connect to chrome|failed to fetch browser websocket url/i.test(result);
826
+ }
827
+ function browserUnreachableError() {
828
+ return new CdpError("The browser is not reachable. It may be running without a debugging port, or the bridge is pointing at a browser that has closed.", "BROWSER_ERROR", [
829
+ "Run `opera-browser-cli doctor` to check the profile and bridge state",
830
+ "Restart the running browser with a debug port: `opera-browser-cli open <url> --takeover`",
831
+ "Or use a separate profile (no flag) if the browser cannot be restarted",
832
+ ]);
833
+ }
312
834
  export function mapErrorMessage(message) {
835
+ if (isAuthFailure(message)) {
836
+ return new CdpError("Bridge rejected the auth token", "BRIDGE_NOT_READY", [
837
+ "Run `opera-browser-cli restart` to issue a fresh token",
838
+ "Run `opera-browser-cli doctor` to inspect the bridge state",
839
+ ]);
840
+ }
313
841
  if (message.includes("ECONNREFUSED") || message.includes("ECONNRESET")) {
314
842
  return new CdpError("Bridge is not running", "BRIDGE_NOT_READY", [
315
843
  "Run `opera-browser-cli open <url>` — the bridge starts automatically",
844
+ "Run `opera-browser-cli restart` if it keeps failing",
316
845
  ]);
317
846
  }
318
847
  if ((message.includes("uid") || message.includes("element")) &&
@@ -335,9 +864,9 @@ export function mapErrorMessage(message) {
335
864
  if (message.includes("User is not signed in") ||
336
865
  (message.includes("Opera.dispatchAction") &&
337
866
  message.includes("not signed in"))) {
338
- return new CdpError("Opera: user is not signed in", "BROWSER_ERROR", [
339
- "Sign in to your Opera account to use this feature",
340
- "Run `opera-browser-cli setup` to configure the executable path",
867
+ return new CdpError("Opera: user is not signed in", "AUTH_REQUIRED", [
868
+ "Run `opera-browser-cli login` to sign in to your Opera account",
869
+ "Run `opera-browser-cli doctor` to inspect the current configuration",
341
870
  ]);
342
871
  }
343
872
  // Try to parse JSON error
@@ -355,55 +884,92 @@ export function mapErrorMessage(message) {
355
884
  return new CdpError(message, "UNKNOWN");
356
885
  }
357
886
  /**
358
- * Inspect the bridge without starting it. Used by `opera-browser-cli doctor`.
887
+ * Inspect the bridge without starting it. Used by `doctor` and `status`.
359
888
  */
360
889
  export async function getBridgeStatus() {
361
- const pidInfo = readPidFile();
362
- if (!pidInfo) {
890
+ const expectedVersion = getPackageVersion();
891
+ const base = {
892
+ pidFileExists: false,
893
+ processAlive: false,
894
+ healthy: false,
895
+ port: null,
896
+ pid: null,
897
+ runningVersion: null,
898
+ expectedVersion,
899
+ versionSkew: false,
900
+ stalePidFile: false,
901
+ };
902
+ // A live bridge is the best source of truth, wherever its port came from.
903
+ for (const { port, health } of await probeAll(candidatePorts())) {
904
+ if (!isOurBridge(health))
905
+ continue;
363
906
  return {
364
- pidFileExists: false,
365
- processAlive: false,
366
- healthy: false,
367
- port: null,
368
- pid: null,
907
+ ...base,
908
+ pidFileExists: existsSync(PID_FILE),
909
+ processAlive: true,
910
+ healthy: isUsableBridge(health, expectedVersion),
911
+ port,
912
+ pid: health.pid > 0 ? health.pid : (readPidFile()?.pid ?? null),
913
+ runningVersion: health.version,
914
+ versionSkew: health.version !== expectedVersion,
369
915
  };
370
916
  }
371
- const alive = isProcessAlive(pidInfo.pid);
372
- const healthy = alive ? await isBridgeHealthy(pidInfo.port) : false;
917
+ const info = readPidFile();
918
+ if (!info)
919
+ return base;
920
+ const fromThisBoot = pidFileIsFromThisBoot(info);
921
+ const alive = fromThisBoot && isProcessAlive(info.pid);
373
922
  return {
923
+ ...base,
374
924
  pidFileExists: true,
375
925
  processAlive: alive,
376
- healthy,
377
- port: pidInfo.port,
378
- pid: pidInfo.pid,
926
+ port: info.port,
927
+ pid: info.pid,
928
+ // Nothing answered on any port, so a PID file that survives is stale
929
+ // whether its process is gone or merely wedged.
930
+ stalePidFile: !alive || !fromThisBoot,
379
931
  };
380
932
  }
933
+ /** The bridge to read from, without starting one. */
934
+ async function activeBridge() {
935
+ const info = readPidFile();
936
+ if (info) {
937
+ const health = await probeHealth(info.port);
938
+ if (isUsableBridge(health, getPackageVersion())) {
939
+ return { port: info.port, token: info.token ?? null };
940
+ }
941
+ }
942
+ const port = await findUsableBridge(candidatePorts());
943
+ if (port === null)
944
+ return null;
945
+ return { port, token: readBridgeToken() };
946
+ }
381
947
  /** Retrieve the most recent snapshot the bridge has cached, without triggering a new one. */
382
948
  export async function getLastSnapshot() {
383
- const pidInfo = readPidFile();
384
- if (!pidInfo || !isProcessAlive(pidInfo.pid))
949
+ const bridge = await activeBridge();
950
+ if (bridge === null)
385
951
  return null;
386
952
  try {
387
- const resp = await httpGet(pidInfo.port, "/last-snapshot", 2000, pidInfo.token);
953
+ const resp = await httpGet(bridge.port, "/last-snapshot", 2000, bridge.token);
388
954
  const data = JSON.parse(resp);
389
955
  if (data.error || !data.raw)
390
956
  return null;
391
- return { raw: data.raw, pageUrl: data.pageUrl ?? null, capturedAt: data.capturedAt ?? 0 };
957
+ return {
958
+ raw: data.raw,
959
+ pageUrl: data.pageUrl ?? null,
960
+ capturedAt: data.capturedAt ?? 0,
961
+ };
392
962
  }
393
963
  catch {
394
964
  return null;
395
965
  }
396
966
  }
397
967
  export async function getSessionSnapshotIfRunning() {
398
- const pidInfo = readPidFile();
399
- if (!pidInfo || !isProcessAlive(pidInfo.pid)) {
400
- return null;
401
- }
402
- if (!(await isBridgeHealthy(pidInfo.port))) {
968
+ const bridge = await activeBridge();
969
+ if (bridge === null)
403
970
  return null;
404
- }
405
971
  try {
406
- const resp = await httpPost(pidInfo.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, pidInfo.token);
972
+ const resp = await httpPost(bridge.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, bridge.token);
407
973
  const data = JSON.parse(resp);
408
974
  if (data.error)
409
975
  return null;
@@ -413,18 +979,4 @@ export async function getSessionSnapshotIfRunning() {
413
979
  return null;
414
980
  }
415
981
  }
416
- /**
417
- * Stop the bridge process.
418
- */
419
- export function stopBridge() {
420
- const pidInfo = readPidFile();
421
- if (!pidInfo) {
422
- return false;
423
- }
424
- if (isProcessAlive(pidInfo.pid)) {
425
- process.kill(pidInfo.pid, "SIGTERM");
426
- return true;
427
- }
428
- return false;
429
- }
430
982
  //# sourceMappingURL=client.js.map