opera-devtools-mcp 0.6.1 → 0.8.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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/build/src/McpContext.js +78 -41
  3. package/build/src/McpPage.js +5 -1
  4. package/build/src/ToolHandler.js +54 -76
  5. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  6. package/build/src/bin/chrome-devtools.js +56 -124
  7. package/build/src/bin/opera-browser-cli.js +102 -0
  8. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  9. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  10. package/build/src/bin/opera-devtools-mcp.js +20 -1
  11. package/build/src/browser.js +23 -25
  12. package/build/src/config/browser-options.js +126 -0
  13. package/build/src/config/category-options.js +81 -0
  14. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  15. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  16. package/build/src/daemon/client.js +55 -40
  17. package/build/src/daemon/daemon.js +62 -39
  18. package/build/src/daemon/utils.js +6 -0
  19. package/build/src/devtools/DevtoolsUtils.js +27 -21
  20. package/build/src/formatters/NetworkFormatter.js +5 -2
  21. package/build/src/index.js +166 -100
  22. package/build/src/opera/branding.js +4 -2
  23. package/build/src/opera/browserActivity.js +62 -0
  24. package/build/src/opera/browserCleanup.js +123 -0
  25. package/build/src/opera/browserErrors.js +66 -0
  26. package/build/src/opera/browserFlags.js +184 -38
  27. package/build/src/opera/browserTarget.js +513 -0
  28. package/build/src/opera/cdpErrors.js +391 -0
  29. package/build/src/opera/cliCommands.js +378 -0
  30. package/build/src/opera/cliOutput.js +284 -0
  31. package/build/src/opera/compactSnapshot.js +525 -0
  32. package/build/src/opera/config.js +166 -0
  33. package/build/src/opera/daemonLifecycle.js +257 -0
  34. package/build/src/opera/daemonLog.js +103 -0
  35. package/build/src/opera/daemonPidFile.js +83 -0
  36. package/build/src/opera/daemonShutdown.js +66 -0
  37. package/build/src/opera/daemonSocket.js +87 -0
  38. package/build/src/opera/daemonStreaming.js +130 -0
  39. package/build/src/opera/daemonToolCall.js +26 -0
  40. package/build/src/opera/detect.js +114 -0
  41. package/build/src/opera/doctor.js +317 -0
  42. package/build/src/opera/envConfig.js +229 -0
  43. package/build/src/opera/launcherNotice.js +116 -0
  44. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  45. package/build/src/opera/logs.js +133 -0
  46. package/build/src/opera/mcpServerSupervisor.js +128 -0
  47. package/build/src/opera/migrationShared.js +164 -0
  48. package/build/src/opera/operaPages.js +56 -0
  49. package/build/src/opera/pageIdRouting.js +35 -0
  50. package/build/src/opera/pageRecovery.js +53 -0
  51. package/build/src/opera/profile.js +270 -0
  52. package/build/src/opera/refArgs.js +36 -0
  53. package/build/src/opera/serviceWorkerRetry.js +46 -4
  54. package/build/src/opera/setup.js +290 -0
  55. package/build/src/opera/skills/SKILL.md +160 -0
  56. package/build/src/opera/streamingTools.js +73 -0
  57. package/build/src/opera/suggestions.js +67 -0
  58. package/build/src/opera/toolHandlerHooks.js +25 -1
  59. package/build/src/opera/tools/opera.js +107 -38
  60. package/build/src/opera/urlResolver.js +69 -0
  61. package/build/src/opera/webStorageWarning.js +92 -0
  62. package/build/src/processors/HeapSnapshotManager.js +12 -0
  63. package/build/src/telemetry/ClearcutLogger.js +19 -6
  64. package/build/src/telemetry/transformation.js +4 -0
  65. package/build/src/telemetry/types.js +4 -0
  66. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  67. package/build/src/third_party/bundled-packages.json +3 -3
  68. package/build/src/third_party/devtools-formatter-worker.js +23 -0
  69. package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
  70. package/build/src/third_party/index.js +15460 -14256
  71. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  72. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  73. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  74. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  75. package/build/src/tools/ToolDefinition.js +15 -0
  76. package/build/src/tools/categories.js +0 -6
  77. package/build/src/tools/comments.js +90 -0
  78. package/build/src/tools/console.js +1 -1
  79. package/build/src/tools/emulation.js +1 -1
  80. package/build/src/tools/extensions.js +1 -1
  81. package/build/src/tools/memory.js +60 -6
  82. package/build/src/tools/network.js +2 -2
  83. package/build/src/tools/pages.js +26 -15
  84. package/build/src/tools/performance.js +4 -3
  85. package/build/src/tools/screencast.js +3 -2
  86. package/build/src/tools/screenshot.js +39 -8
  87. package/build/src/tools/script.js +17 -4
  88. package/build/src/tools/slim/tools.js +41 -33
  89. package/build/src/tools/snapshot.js +1 -1
  90. package/build/src/tools/tools.js +2 -0
  91. package/build/src/utils/WaitForHelper.js +12 -1
  92. package/build/src/utils/bytes.js +105 -0
  93. package/build/src/utils/url.js +79 -0
  94. package/build/src/version.js +1 -1
  95. package/package.json +29 -8
  96. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,513 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Which browser a command should drive, and what to do when the configured
9
+ * profile is already held by a browser that cannot be driven.
10
+ *
11
+ * Ported from opera-browser-cli's `src/browser-target.ts` and the
12
+ * `preflightBrowser` / `resolveBrowserConflict` half of its `src/cli.ts`. The
13
+ * split is kept: the mechanics (resolve a target, ask a running browser to
14
+ * quit, start one that can be attached to) live here, and the CLI only calls
15
+ * the preflight.
16
+ *
17
+ * The constraint that shapes all of this: `--remote-debugging-port` is a
18
+ * startup-only flag. A browser the user opened normally cannot be attached to,
19
+ * ever. So there is no way to "connect to the Opera that is already open" —
20
+ * only ways to arrange that the open Opera was started with a port in the first
21
+ * place, and a way to detect it when it was.
22
+ *
23
+ * That gives three states for a configured profile:
24
+ *
25
+ * free → let the daemon launch it, as before.
26
+ * locked, debug port live → attach. No prompt, no restart, nothing to do.
27
+ * locked, no debug port → a conflict only the user can resolve, by
28
+ * letting us restart their browser.
29
+ *
30
+ * The second case is the one that makes this feel automatic: once a browser has
31
+ * been started with a port — by us, or by the user following the flags in
32
+ * `docs/troubleshooting.md` — every later command finds it on its own via
33
+ * DevToolsActivePort.
34
+ *
35
+ * The third case is what the user sees as the `A browser is already running
36
+ * with the profile …` error. Resolving it restarts somebody's browser, so it
37
+ * happens only on an explicit yes: a terminal prompt, or `--takeover` for
38
+ * scripted callers. Everything else falls back to a separate profile, which
39
+ * always works and costs only a sign-in.
40
+ */
41
+ import { spawn } from 'node:child_process';
42
+ import { existsSync, unlinkSync } from 'node:fs';
43
+ import { join, resolve } from 'node:path';
44
+ import { createInterface } from 'node:readline';
45
+ import { sendCommand, stopDaemon } from '../daemon/client.js';
46
+ import { isDaemonRunning } from '../daemon/utils.js';
47
+ import { CLI_BIN_NAME } from './branding.js';
48
+ import { launchedUserDataDir, storedBrowserUrl } from './browserFlags.js';
49
+ import { CdpError } from './cdpErrors.js';
50
+ import { getStateDir } from './envConfig.js';
51
+ import { findAttachableEndpoint, inspectProfileLock, isProcessAlive, localHostNames, probeDevToolsEndpoint, readDevToolsPort, } from './profile.js';
52
+ /** Decide what the daemon should talk to, before it starts. */
53
+ export async function resolveBrowserTarget(ctx) {
54
+ // An explicit browser URL is the user telling us they manage the browser.
55
+ if (ctx.browserUrl) {
56
+ return { mode: 'attach', url: ctx.browserUrl, note: 'OPERA_CLI_BROWSER_URL' };
57
+ }
58
+ // No persistent profile means an isolated one, which nothing else can hold.
59
+ if (!ctx.userDataDir) {
60
+ return { mode: 'managed', note: 'isolated profile' };
61
+ }
62
+ // A live debug port wins outright: the browser is running and reachable, so
63
+ // there is no conflict to resolve regardless of what the lock says.
64
+ const attachable = await findAttachableEndpoint(ctx.userDataDir);
65
+ if (attachable !== null) {
66
+ return {
67
+ mode: 'attach',
68
+ url: attachable.url,
69
+ note: `running ${attachable.identity.browser}`,
70
+ };
71
+ }
72
+ const lock = inspectProfileLock(ctx.userDataDir);
73
+ if (lock.state === 'free') {
74
+ return { mode: 'managed', note: 'profile is free' };
75
+ }
76
+ return { mode: 'conflict', userDataDir: ctx.userDataDir, lock };
77
+ }
78
+ function sleep(ms) {
79
+ const { promise, resolve: resolveSleep } = Promise.withResolvers();
80
+ setTimeout(resolveSleep, ms);
81
+ return promise;
82
+ }
83
+ /**
84
+ * Ask a running browser to quit, and wait for it to let go of the profile.
85
+ *
86
+ * SIGTERM only. Chromium treats it as a clean shutdown — session saved, profile
87
+ * flushed — whereas SIGKILL risks a corrupted profile and loses the user's
88
+ * tabs. If it will not go, we say so rather than escalating: this is somebody's
89
+ * browser, and forcing it is not ours to decide.
90
+ *
91
+ * Two signals mean the profile is free, and the wait takes either: Chromium
92
+ * removes `SingletonLock` as it goes, and a browser that died without removing
93
+ * it leaves the lock naming a process that is gone. The names that make a lock
94
+ * this machine's are derived once, before the loop: on macOS that derivation is
95
+ * a `scutil` subprocess, and a 20-second wait would otherwise run it eighty
96
+ * times for an answer that cannot change while we wait.
97
+ */
98
+ export async function quitBrowser(lock, userDataDir, timeoutMs = 20_000) {
99
+ if (lock.pid === null) {
100
+ return { ok: false, reason: 'no-pid' };
101
+ }
102
+ try {
103
+ process.kill(lock.pid, 'SIGTERM');
104
+ }
105
+ catch {
106
+ // Already gone between inspection and now — that is a success.
107
+ return { ok: true };
108
+ }
109
+ const localNames = localHostNames();
110
+ const deadline = Date.now() + timeoutMs;
111
+ while (Date.now() < deadline) {
112
+ await sleep(250);
113
+ if (inspectProfileLock(userDataDir, isProcessAlive, process.platform, localNames).state === 'free') {
114
+ return { ok: true };
115
+ }
116
+ }
117
+ return { ok: false, reason: 'timeout' };
118
+ }
119
+ /**
120
+ * Start a browser we can attach to, and that outlives us.
121
+ *
122
+ * `--remote-debugging-port=0` has Chromium pick a free port itself and record
123
+ * it in `DevToolsActivePort`. That satisfies two requirements at once: we never
124
+ * squat a predictable port like 9222, and the port is discoverable by every
125
+ * later command without being written to any config.
126
+ *
127
+ * The browser is detached deliberately. Having just restarted the user's
128
+ * browser, closing it again when the daemon stops would be a poor trade.
129
+ */
130
+ export async function launchAttachableBrowser(executablePath, userDataDir, extraArgs = [], timeoutMs = 30_000) {
131
+ if (!executablePath || !existsSync(executablePath)) {
132
+ return { ok: false, reason: 'no-executable' };
133
+ }
134
+ // Chromium rewrites this on startup, but clearing it first means a stale port
135
+ // from a previous run can never be mistaken for the new browser's.
136
+ const portFile = join(userDataDir, 'DevToolsActivePort');
137
+ try {
138
+ unlinkSync(portFile);
139
+ }
140
+ catch {
141
+ // Absent already — fine.
142
+ }
143
+ const args = [
144
+ ...browserLaunchArgs(userDataDir),
145
+ // We just took their browser away; give the tabs back.
146
+ '--restore-last-session',
147
+ ...extraArgs,
148
+ ];
149
+ let child;
150
+ try {
151
+ child = spawn(executablePath, args, { stdio: 'ignore', detached: true });
152
+ }
153
+ catch (error) {
154
+ return {
155
+ ok: false,
156
+ reason: 'spawn-failed',
157
+ detail: error instanceof Error ? error.message : String(error),
158
+ };
159
+ }
160
+ child.unref();
161
+ let spawnError = null;
162
+ child.on('error', error => {
163
+ spawnError = error.message;
164
+ });
165
+ const deadline = Date.now() + timeoutMs;
166
+ while (Date.now() < deadline) {
167
+ if (spawnError !== null) {
168
+ return { ok: false, reason: 'spawn-failed', detail: spawnError };
169
+ }
170
+ const port = readDevToolsPort(userDataDir);
171
+ if (port !== null && (await probeDevToolsEndpoint(port)) !== null) {
172
+ return { ok: true, url: `http://127.0.0.1:${port}` };
173
+ }
174
+ await sleep(250);
175
+ }
176
+ return { ok: false, reason: 'timeout' };
177
+ }
178
+ /**
179
+ * The flags a user needs to start Opera themselves so the CLI can attach.
180
+ *
181
+ * Deliberately not `--remote-allow-origins=*`: Chromium's default rejection of
182
+ * CDP WebSocket upgrades that carry an Origin header is what stops a web page
183
+ * from driving the browser, and this profile is logged into everything.
184
+ */
185
+ export function browserLaunchArgs(userDataDir) {
186
+ const args = [
187
+ '--remote-debugging-port=0',
188
+ '--remote-debugging-address=127.0.0.1',
189
+ ];
190
+ if (userDataDir) {
191
+ args.push(`--user-data-dir=${userDataDir}`);
192
+ }
193
+ return args;
194
+ }
195
+ /** The throwaway profile the fallback uses when the configured one is taken. */
196
+ export function separateProfileDir() {
197
+ return join(getStateDir(), 'profile');
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Preflight
201
+ // ---------------------------------------------------------------------------
202
+ /** Commands that never touch a browser, so never need a browser chosen. */
203
+ const BROWSER_SKIP_COMMANDS = {
204
+ setup: true,
205
+ doctor: true,
206
+ logs: true,
207
+ status: true,
208
+ stop: true,
209
+ };
210
+ /**
211
+ * Flags that pick the browser explicitly. A command carrying one of these is
212
+ * the user deciding, and a decision is not something to second-guess — the same
213
+ * rule `applyEnvToArgv` follows, where an explicit flag beats the environment.
214
+ */
215
+ function picksBrowserOnArgv(argv) {
216
+ const flags = [
217
+ '--browserUrl',
218
+ '--browser-url',
219
+ '-u',
220
+ '--wsEndpoint',
221
+ '--ws-endpoint',
222
+ '-w',
223
+ '--autoConnect',
224
+ '--auto-connect',
225
+ '--isolated',
226
+ '--userDataDir',
227
+ '--user-data-dir',
228
+ ];
229
+ return argv.some(arg => flags.some(flag => arg === flag || arg.startsWith(`${flag}=`)));
230
+ }
231
+ /** True when the running daemon launched its browser on this very profile. */
232
+ function daemonOwnsProfile(daemonArgs, userDataDir) {
233
+ const launched = launchedUserDataDir(daemonArgs);
234
+ return launched !== undefined && resolve(launched) === resolve(userDataDir);
235
+ }
236
+ const CONFLICT_EXPLANATION = (userDataDir) => `\nOpera is already running on the profile ${CLI_BIN_NAME} is configured to use:\n` +
237
+ ` ${userDataDir}\n\n` +
238
+ 'A browser can only be automated if it was started with a debugging port,\n' +
239
+ 'and that flag cannot be added to a browser that is already open.\n\n' +
240
+ ' [1] Restart Opera now so the CLI can drive it (tabs are restored)\n' +
241
+ ' [2] Use a separate profile instead (you will need to sign in there)\n\n' +
242
+ 'Restarting opens a local debugging port for as long as that browser runs.\n';
243
+ /**
244
+ * The question, without the explanation: readline prints whatever it is given,
245
+ * so the explanation is written separately and this is the only part that would
246
+ * otherwise appear twice on a terminal.
247
+ */
248
+ const CONFLICT_QUESTION = 'Select [1/2] (default 2): ';
249
+ async function askOnTerminal(question) {
250
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
251
+ const { promise, resolve: resolveAnswer } = Promise.withResolvers();
252
+ rl.question(question, resolveAnswer);
253
+ // Ctrl-D ends the question without an answer. The fallback is the wrong
254
+ // answer to hang on, and an empty answer selects it.
255
+ rl.once('close', () => resolveAnswer(''));
256
+ try {
257
+ return await promise;
258
+ }
259
+ finally {
260
+ rl.close();
261
+ }
262
+ }
263
+ /** Long enough for a busy daemon to answer, short enough not to stall a command. */
264
+ const DAEMON_STATUS_TIMEOUT_MS = 5_000;
265
+ async function defaultDaemonArgs(sessionId) {
266
+ if (!isDaemonRunning(sessionId)) {
267
+ return null;
268
+ }
269
+ try {
270
+ const response = await sendCommand({ method: 'status' }, sessionId, DAEMON_STATUS_TIMEOUT_MS);
271
+ if (!response.success) {
272
+ return [];
273
+ }
274
+ const status = JSON.parse(response.result);
275
+ return status.args;
276
+ }
277
+ catch {
278
+ // A daemon that cannot answer owns nothing we can compare against, so the
279
+ // profile it holds is treated as somebody else's — and a command that is
280
+ // about to use that daemon must not be failed by a probe about it.
281
+ return [];
282
+ }
283
+ }
284
+ const defaultPreflightDeps = {
285
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
286
+ ask: askOnTerminal,
287
+ resolveTarget: resolveBrowserTarget,
288
+ daemonArgs: defaultDaemonArgs,
289
+ quit: quitBrowser,
290
+ launch: launchAttachableBrowser,
291
+ stopDaemon,
292
+ env: process.env,
293
+ writeOut: text => process.stdout.write(text),
294
+ write: text => process.stderr.write(text),
295
+ };
296
+ /**
297
+ * Settle the conflict the prompt is about.
298
+ *
299
+ * `separate` is the answer that always works and needs nobody's browser to be
300
+ * touched, so it is the default for every caller that cannot ask: no terminal,
301
+ * or an answer that is not 1.
302
+ */
303
+ async function resolveConflict(target, options) {
304
+ const { deps } = options;
305
+ let choice = options.takeover
306
+ ? 'takeover'
307
+ : 'separate';
308
+ if (!options.takeover && deps.interactive) {
309
+ deps.writeOut(CONFLICT_EXPLANATION(target.userDataDir));
310
+ const answer = (await deps.ask(CONFLICT_QUESTION)).trim().toLowerCase();
311
+ if (answer === '1' || answer === 'y') {
312
+ choice = 'takeover';
313
+ }
314
+ }
315
+ if (choice === 'separate') {
316
+ const dir = separateProfileDir();
317
+ deps.env.OPERA_CLI_USER_DATA_DIR = dir;
318
+ deps.write(`note: Opera is running on the configured profile; using ${dir} for this run.\n` +
319
+ ' Start Opera with a debugging port (--remote-debugging-port=0) so the CLI can attach to it instead.\n');
320
+ }
321
+ else {
322
+ const quit = await deps.quit(target.lock, target.userDataDir);
323
+ if (!quit.ok) {
324
+ throw new CdpError(quit.reason === 'no-pid'
325
+ ? 'Could not identify the process holding the profile, so it was not signalled.'
326
+ : 'Opera did not shut down within 20s.', 'BROWSER_ERROR', [
327
+ 'Quit Opera yourself, then re-run the command',
328
+ 'Or restart it with a debugging port (--remote-debugging-port=0) so the CLI can attach instead',
329
+ ]);
330
+ }
331
+ const launched = await deps.launch(deps.env.OPERA_CLI_EXECUTABLE_PATH, target.userDataDir);
332
+ if (!launched.ok) {
333
+ throw new CdpError(`Opera was stopped but could not be restarted (${launched.reason}${launched.detail ? `: ${launched.detail}` : ''}).`, 'BROWSER_ERROR', [
334
+ 'Start Opera yourself, then re-run the command',
335
+ 'Or restart it with a debugging port (--remote-debugging-port=0) so the CLI can attach instead',
336
+ ]);
337
+ }
338
+ deps.env.OPERA_CLI_BROWSER_URL = launched.url;
339
+ deps.write(`note: restarted Opera and attached at ${launched.url}\n`);
340
+ }
341
+ }
342
+ /**
343
+ * Take down a daemon that would keep driving the browser it was started with.
344
+ *
345
+ * A daemon fixes its browser at startup — the attach URL, the profile, the
346
+ * flags — so a new selection only reaches the next one. Nothing here restarts
347
+ * it; the caller starts a daemon on the selection it now holds.
348
+ */
349
+ async function retireDaemonForNewSelection(sessionId, deps) {
350
+ if ((await deps.daemonArgs(sessionId)) === null) {
351
+ return;
352
+ }
353
+ deps.write('note: browser selection changed; restarting the daemon.\n');
354
+ await deps.stopDaemon(sessionId);
355
+ }
356
+ /**
357
+ * Settle whichever browser the environment points at, and report whether that
358
+ * changed it — so a caller that already tried something can try again.
359
+ *
360
+ * Two callers need this. The preflight runs it before a command starts a daemon
361
+ * (`preflightBrowser`), and the tool commands run it when a daemon reports the
362
+ * one failure this can fix (`cliCommands.ts`): a launch that never happened
363
+ * because a browser was already holding the profile, or an attach to a browser
364
+ * that is no longer there. The second exists because the first cannot see
365
+ * everything — a daemon started before that browser was opened, or by an older
366
+ * CLI, is pinned to the profile it was given, and no amount of looking at the
367
+ * environment reveals that its browser is missing.
368
+ */
369
+ export async function settleBrowserConflict(sessionId, options, deps = defaultPreflightDeps) {
370
+ const env = deps.env;
371
+ const userDataDir = env.OPERA_CLI_USER_DATA_DIR;
372
+ const target = await deps.resolveTarget({
373
+ browserUrl: env.OPERA_CLI_BROWSER_URL,
374
+ userDataDir,
375
+ executablePath: env.OPERA_CLI_EXECUTABLE_PATH,
376
+ });
377
+ let changed = false;
378
+ if (target.mode === 'attach') {
379
+ if (env.OPERA_CLI_BROWSER_URL !== target.url) {
380
+ // A live debug port on the configured profile. Set the attach URL so the
381
+ // daemon the caller is about to start attaches to it rather than
382
+ // launching — and so the tools' page ownership follows the browser in
383
+ // use.
384
+ env.OPERA_CLI_BROWSER_URL = target.url;
385
+ changed = true;
386
+ }
387
+ }
388
+ else if (target.mode === 'conflict' ||
389
+ options.reason === 'profile-in-use') {
390
+ // An isolated profile cannot be held by anybody else, so a failure that
391
+ // says otherwise is not one this can settle.
392
+ if (!userDataDir) {
393
+ return false;
394
+ }
395
+ await resolveConflict(target.mode === 'conflict'
396
+ ? target
397
+ : {
398
+ mode: 'conflict',
399
+ userDataDir,
400
+ lock: { state: 'unknown', pid: null, hostname: null },
401
+ }, { takeover: options.takeover, deps });
402
+ changed = true;
403
+ }
404
+ else if (options.reason !== 'unreachable') {
405
+ return false;
406
+ }
407
+ if (!changed && !(await daemonPinnedToDiscoveredAttach(sessionId, deps))) {
408
+ return false;
409
+ }
410
+ await retireDaemonForNewSelection(sessionId, deps);
411
+ return true;
412
+ }
413
+ /**
414
+ * Whether a daemon is attached to a URL the CLI discovered for it, and which
415
+ * the environment no longer names.
416
+ *
417
+ * That daemon cannot recover by itself: an attached browser is never restarted,
418
+ * so it keeps trying a port nothing answers. Retiring it lets the caller's
419
+ * restart re-derive the browser — attach to a live endpoint, launch when the
420
+ * profile is free, or ask when it is held. A URL the user configured, or a
421
+ * `--wsEndpoint` / `--autoConnect` they typed, is left alone: their browser is
422
+ * theirs to start again, which is what the failure says.
423
+ */
424
+ async function daemonPinnedToDiscoveredAttach(sessionId, deps) {
425
+ if (deps.env.OPERA_CLI_BROWSER_URL !== undefined) {
426
+ return false;
427
+ }
428
+ const daemonArgs = await deps.daemonArgs(sessionId);
429
+ return daemonArgs !== null && storedBrowserUrl(daemonArgs) !== undefined;
430
+ }
431
+ /**
432
+ * Work out which browser the command about to run should drive.
433
+ *
434
+ * Runs in the CLI rather than the daemon because settling a conflict may need
435
+ * to ask the user something, and the daemon is detached with no terminal —
436
+ * which is also why the decision has to be written into the environment before
437
+ * the command starts the daemon: both processes inherit it from here.
438
+ *
439
+ * The browser is only re-selected when a daemon is not already driving one: a
440
+ * daemon fixes its browser at startup, so the question is settled for as long
441
+ * as it lives. `start` is the exception — its handler stops the daemon and
442
+ * picks fresh browser options — and even then the lock a restart can see
443
+ * belongs to the browser it is about to release, so it is not a conflict to
444
+ * resolve. A daemon whose browser never started is the other exception, and it
445
+ * is settled where its failure is reported instead.
446
+ */
447
+ export async function preflightBrowser(argv, sessionId, takeover, deps = defaultPreflightDeps) {
448
+ const command = argv[0];
449
+ if (command === undefined || BROWSER_SKIP_COMMANDS[command] === true) {
450
+ return;
451
+ }
452
+ // `--help` on any command is a question about the command, not a job for the
453
+ // browser; asking about a profile conflict before printing help would be rude.
454
+ if (argv.some(arg => arg === '--help' ||
455
+ arg === '-h' ||
456
+ arg === '--version' ||
457
+ arg === '-v' ||
458
+ arg === '-V')) {
459
+ return;
460
+ }
461
+ const env = deps.env;
462
+ // An explicit browser URL, or no persistent profile, means no conflict is
463
+ // possible.
464
+ if (env.OPERA_CLI_BROWSER_URL || !env.OPERA_CLI_USER_DATA_DIR) {
465
+ return;
466
+ }
467
+ if (picksBrowserOnArgv(argv)) {
468
+ return;
469
+ }
470
+ const userDataDir = env.OPERA_CLI_USER_DATA_DIR;
471
+ const daemonArgs = await deps.daemonArgs(sessionId);
472
+ if (daemonArgs !== null &&
473
+ (command !== 'start' || daemonOwnsProfile(daemonArgs, userDataDir))) {
474
+ return;
475
+ }
476
+ await settleBrowserConflict(sessionId, { takeover }, deps);
477
+ }
478
+ /**
479
+ * Remove `--takeover` wherever it appears and report whether it was given. It
480
+ * belongs to the preflight, not to any command, and every command parser is
481
+ * strict — so the flag is consumed here rather than declared on each of them.
482
+ */
483
+ export function extractTakeoverFlag(argv) {
484
+ let takeover = false;
485
+ for (let index = argv.length - 1; index >= 0; index--) {
486
+ if (argv[index] === '--takeover') {
487
+ takeover = true;
488
+ argv.splice(index, 1);
489
+ }
490
+ }
491
+ return takeover;
492
+ }
493
+ /**
494
+ * The `--sessionId` a command will be scoped to, read before yargs has parsed
495
+ * it: the daemon the preflight asks about has to be the session's own. `''` is
496
+ * the default yargs applies to a command that passes none — the two must agree,
497
+ * or the preflight would inspect an empty session nobody is using.
498
+ */
499
+ export function sessionIdFromArgv(argv) {
500
+ for (let index = 0; index < argv.length; index++) {
501
+ const arg = argv[index];
502
+ for (const name of ['--sessionId', '--session-id']) {
503
+ if (arg === name && index + 1 < argv.length) {
504
+ return argv[index + 1];
505
+ }
506
+ if (arg.startsWith(`${name}=`)) {
507
+ return arg.slice(name.length + 1);
508
+ }
509
+ }
510
+ }
511
+ return '';
512
+ }
513
+ //# sourceMappingURL=browserTarget.js.map