opera-devtools-mcp 0.7.0 → 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 (55) hide show
  1. package/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,229 @@
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
+ import { readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { STATE_DIR_NAME } from './branding.js';
11
+ /**
12
+ * The five `OPERA_CLI_*` variables the fork recognises. Ported from
13
+ * opera-browser-cli's `KNOWN_CONFIG_KEYS` and filtered to Phase 1a scope —
14
+ * `PORT`, `MCP_BIN`, `ENABLE_HOOKS`, `TAKEOVER` and `DEV` are dropped or
15
+ * deferred to feature-owning phases. Also the promotion allowlist: only these
16
+ * keys may reach `process.env`, so a typo (or a stray `NODE_OPTIONS`) in the
17
+ * config file cannot silently become part of the daemon's environment.
18
+ */
19
+ export const KNOWN_CONFIG_KEYS = [
20
+ 'OPERA_CLI_EXECUTABLE_PATH',
21
+ 'OPERA_CLI_BROWSER_URL',
22
+ 'OPERA_CLI_USER_DATA_DIR',
23
+ 'OPERA_CLI_HEADED',
24
+ 'OPERA_CLI_CHROME_ARGS',
25
+ ];
26
+ /** `~/.opera-browser-cli`, derived from the branding constant. */
27
+ export function getStateDir(home = homedir()) {
28
+ return join(home, STATE_DIR_NAME);
29
+ }
30
+ /** `~/.opera-browser-cli/config`, derived from the branding constant. */
31
+ export function getConfigFile(home = homedir()) {
32
+ return join(getStateDir(home), 'config');
33
+ }
34
+ /** Module-load snapshot of `getConfigFile()`; `readConfigFile` defaults to it. */
35
+ const DEFAULT_CONFIG_FILE = getConfigFile();
36
+ /**
37
+ * Strip a matching quote pair and unescape the escaped quotes inside it.
38
+ * Ported verbatim from opera-browser-cli's `parseConfigValue`.
39
+ */
40
+ export function parseConfigValue(raw) {
41
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
42
+ (raw.startsWith("'") && raw.endsWith("'"))) {
43
+ return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'");
44
+ }
45
+ return raw;
46
+ }
47
+ /** Levenshtein distance, capped — only used to suggest a corrected key. */
48
+ function editDistance(a, b) {
49
+ const rows = a.length + 1;
50
+ const cols = b.length + 1;
51
+ let prev = Array.from({ length: cols }, (_, i) => i);
52
+ for (let i = 1; i < rows; i++) {
53
+ const curr = [i, ...Array(cols - 1).fill(0)];
54
+ for (let j = 1; j < cols; j++) {
55
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
56
+ }
57
+ prev = curr;
58
+ }
59
+ return prev[cols - 1];
60
+ }
61
+ /** Config keys this program does not read, with a likely intended key where obvious. */
62
+ export function findUnknownConfigKeys(config) {
63
+ const unknown = [];
64
+ for (const key of Object.keys(config)) {
65
+ if (KNOWN_CONFIG_KEYS.includes(key)) {
66
+ continue;
67
+ }
68
+ let best = null;
69
+ let bestDistance = Number.POSITIVE_INFINITY;
70
+ for (const candidate of KNOWN_CONFIG_KEYS) {
71
+ const distance = editDistance(key, candidate);
72
+ if (distance < bestDistance) {
73
+ bestDistance = distance;
74
+ best = candidate;
75
+ }
76
+ }
77
+ const tolerance = Math.max(4, Math.ceil(key.length / 3));
78
+ unknown.push({ key, suggestion: bestDistance <= tolerance ? best : null });
79
+ }
80
+ return unknown;
81
+ }
82
+ /**
83
+ * Read the config file as KEY=VALUE lines. `#` comments and blank lines are
84
+ * skipped, along with lines without a `=`; an unreadable or missing file reads
85
+ * as `{}` — config is a cache, never a prerequisite.
86
+ *
87
+ * `filePath` exists only as a test seam; production callers use the default.
88
+ */
89
+ export function readConfigFile(filePath = DEFAULT_CONFIG_FILE) {
90
+ const config = {};
91
+ try {
92
+ for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed || trimmed.startsWith('#')) {
95
+ continue;
96
+ }
97
+ const eq = trimmed.indexOf('=');
98
+ if (eq === -1) {
99
+ continue;
100
+ }
101
+ config[trimmed.slice(0, eq).trim()] = parseConfigValue(trimmed.slice(eq + 1).trim());
102
+ }
103
+ }
104
+ catch {
105
+ // Unreadable or missing config is treated as absent — never fail a command over it.
106
+ }
107
+ return config;
108
+ }
109
+ /**
110
+ * Promote the recognised config-file entries into `process.env`, only where the
111
+ * variable is not already set (so environment beats config). Unrecognised keys
112
+ * are never promoted — the file is user-writable data, and handing it arbitrary
113
+ * environment variables (a stray `NODE_OPTIONS`, say) would let it steer the
114
+ * daemon the CLI spawns. They warn instead: once per unrecognised key per call,
115
+ * with the closest suggestion. Never fails a run.
116
+ */
117
+ export function loadOperaCliConfig(filePath = DEFAULT_CONFIG_FILE) {
118
+ const config = readConfigFile(filePath);
119
+ for (const [key, value] of Object.entries(config)) {
120
+ if (KNOWN_CONFIG_KEYS.includes(key) &&
121
+ !(key in process.env)) {
122
+ process.env[key] = value;
123
+ }
124
+ }
125
+ for (const { key, suggestion } of findUnknownConfigKeys(config)) {
126
+ console.error(suggestion
127
+ ? `Warning: unknown config key "${key}" — did you mean "${suggestion}"?`
128
+ : `Warning: unknown config key "${key}".`);
129
+ }
130
+ }
131
+ /** True when any of `flags` (bare or `=value` form) already appears on argv. */
132
+ function hasArg(argv, ...flags) {
133
+ return argv.some(arg => flags.some(flag => arg === flag || arg.startsWith(`${flag}=`)));
134
+ }
135
+ /**
136
+ * Default Chromium arguments Puppeteer injects that must be reverted when
137
+ * launching Opera against a persistent profile. Ported from
138
+ * opera-browser-cli's `buildTransportArgs()`: a mocked keychain and
139
+ * `--password-store=basic` stop the browser decrypting the real Keychain (so
140
+ * the profile launches logged out), while the component-extension and
141
+ * background default blockers stop the Opera AI extension from loading (so
142
+ * `Opera.dispatchAction` has "no target" to dispatch to).
143
+ */
144
+ export const PERSISTENT_PROFILE_IGNORE_DEFAULT_ARGS = [
145
+ '--use-mock-keychain',
146
+ '--password-store=basic',
147
+ '--disable-extensions',
148
+ '--disable-component-extensions-with-background-pages',
149
+ '--disable-default-apps',
150
+ '--disable-background-networking',
151
+ ];
152
+ /**
153
+ * Translate `OPERA_CLI_*` env vars into the equivalent yargs flags and append
154
+ * them to `argv`, only when the flag was not already passed on the command
155
+ * line (so an explicit flag beats the environment). Replaces
156
+ * opera-browser-cli's `buildTransportArgs`, which emitted the same mapping as
157
+ * bridge spawn args rather than argv.
158
+ */
159
+ export function applyEnvToArgv(argv) {
160
+ const env = process.env;
161
+ // The CLI never routes by pageId (it replaces the retired opera-browser-cli).
162
+ // Only CLI-spawned sessions — not direct MCP clients — disable routing on the
163
+ // server.
164
+ if (argv.includes('--viaCli') && !hasArg(argv, '--no-page-id-routing')) {
165
+ argv.push('--no-page-id-routing');
166
+ }
167
+ const headed = env.OPERA_CLI_HEADED?.trim();
168
+ if (headed && !hasArg(argv, '--headless', '--no-headless')) {
169
+ if (headed === '1' || headed.toLowerCase() === 'true') {
170
+ argv.push('--headless=false');
171
+ }
172
+ else if (headed === '0' || headed.toLowerCase() === 'false') {
173
+ argv.push('--headless=true');
174
+ }
175
+ else {
176
+ // `true`/`false` are natural spellings; anything else is a typo that
177
+ // would otherwise be dropped without a trace.
178
+ console.error(`Warning: ignoring OPERA_CLI_HEADED="${env.OPERA_CLI_HEADED}" — expected 1, 0, true, or false.`);
179
+ }
180
+ }
181
+ const chromeArgs = env.OPERA_CLI_CHROME_ARGS;
182
+ if (chromeArgs?.trim() && !hasArg(argv, '--chromeArg', '--chrome-arg')) {
183
+ for (const arg of chromeArgs.trim().split(/\s+/)) {
184
+ argv.push(`--chromeArg=${arg}`);
185
+ }
186
+ }
187
+ const browserUrl = env.OPERA_CLI_BROWSER_URL;
188
+ if (browserUrl && !hasArg(argv, '--browserUrl', '--browser-url', '-u')) {
189
+ argv.push(`--browserUrl=${browserUrl}`);
190
+ }
191
+ // Attaching to an already-running browser (by URL or WS endpoint) means the
192
+ // browser lifecycle is managed externally. `--userDataDir` and
193
+ // `--executablePath` both conflict with a browser URL/WS endpoint in yargs
194
+ // (see `browserOptions.conflicts`), so injecting either from config here
195
+ // aborts argument parsing — the "Arguments userDataDir and browserUrl are
196
+ // mutually exclusive" crash. Mirror opera-browser-cli's `buildTransportArgs`:
197
+ // a browser URL wins and the profile/executable flags are skipped entirely.
198
+ const attachesToBrowser = hasArg(argv, '--browserUrl', '--browser-url', '-u', '--wsEndpoint', '--ws-endpoint', '-w');
199
+ // A persistent profile overrides isolated mode, and `--userDataDir` is
200
+ // mutually exclusive with `--isolated` in yargs — so emit the dir alone, plus
201
+ // the companion flags ported from opera-browser-cli's `buildTransportArgs()`
202
+ // (drop the Puppeteer defaults that block Keychain decryption and extension
203
+ // loading, and load the Opera AI component extension explicitly).
204
+ const userDataDir = env.OPERA_CLI_USER_DATA_DIR;
205
+ if (userDataDir &&
206
+ !attachesToBrowser &&
207
+ // An explicit `--isolated` asks for a throwaway profile, and yargs treats
208
+ // the two as mutually exclusive. The CLI wins here as it does everywhere
209
+ // else in this file: injecting the configured dir alongside `--isolated`
210
+ // made the MCP server exit during argument parsing ("Connection closed" to
211
+ // the daemon, which then tore down), and the next command silently started
212
+ // a daemon on the persistent profile instead.
213
+ !hasArg(argv, '--isolated') &&
214
+ !hasArg(argv, '--userDataDir', '--user-data-dir')) {
215
+ argv.push(`--userDataDir=${userDataDir}`);
216
+ for (const flag of PERSISTENT_PROFILE_IGNORE_DEFAULT_ARGS) {
217
+ argv.push(`--ignoreDefaultChromeArg=${flag}`);
218
+ }
219
+ argv.push('--chromeArg=--show-component-extension-options');
220
+ }
221
+ const executablePath = env.OPERA_CLI_EXECUTABLE_PATH;
222
+ if (executablePath &&
223
+ !attachesToBrowser &&
224
+ !hasArg(argv, '--autoConnect', '--auto-connect') &&
225
+ !hasArg(argv, '--executablePath', '--executable-path', '-e')) {
226
+ argv.push(`--executablePath=${executablePath}`);
227
+ }
228
+ }
229
+ //# sourceMappingURL=envConfig.js.map
@@ -0,0 +1,116 @@
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
+ * What to tell a user whose CLI was delivered by the compatibility launcher.
9
+ *
10
+ * `opera-browser-cli@0.1.55` is a launcher: it declares the old package name
11
+ * and the old bin name, depends on this package, and spawns this CLI. That is
12
+ * how a habit-driven `npm i -g opera-browser-cli` (or `npm update -g
13
+ * opera-browser-cli`) picks up the new implementation without `--force` and
14
+ * without a manual uninstall — npm only retires a foreign binstub on a
15
+ * same-name upgrade.
16
+ *
17
+ * The end state removes the launcher again, and the user has to be told how:
18
+ * npm has no `replaces` field, and an install-time script is blocked by
19
+ * npm ≥12's `allowScripts` gate by default. The launcher therefore marks its
20
+ * child with `OPERA_CLI_LAUNCHER=1` (and passes the prefix it resolved as
21
+ * `OPERA_CLI_LAUNCHER_PREFIX`, for support), and both `--version` and `doctor`
22
+ * — the two commands a stuck user runs — print the retirement recipes when the
23
+ * marker is set.
24
+ *
25
+ * The other direction has no marker to react to: a user who installed this
26
+ * package next to the *pre-launcher* `opera-browser-cli` never runs this CLI at
27
+ * all, they run the old one. `notifyLegacyPackageInstalled` covers that case
28
+ * from the runtime guard, and is the only notice here that reads the filesystem.
29
+ *
30
+ * The recipes are printed verbatim so they can be pasted; a release that
31
+ * publishes the tombstone has to change the text here (see
32
+ * `docs/npm-package-transition.md`).
33
+ */
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { resolveGlobalPrefix, warn } from './migrationShared.js';
37
+ /** Marker the launcher's `bin/cli.js` sets to `1` before it spawns this CLI. */
38
+ const LAUNCHER_ENV = 'OPERA_CLI_LAUNCHER';
39
+ /**
40
+ * Say one line if the pre-launcher `opera-browser-cli` is still installed.
41
+ *
42
+ * Called from the runtime migration guard (`legacyBridgeCleanup`), because the
43
+ * install hook this used to run in is blocked by npm ≥12's `allowScripts` gate.
44
+ * The check is a filesystem read rather than `npm ls -g`, which would spawn a
45
+ * second npm that contends on the arborist lock npm 7+ holds during a global
46
+ * install — observed to hang on npm 9 and 10. The old package is never
47
+ * uninstalled from here for the same reason: the user is pointed at the command
48
+ * instead.
49
+ *
50
+ * Two of the package's releases are the migration working as intended and must
51
+ * not be reported, because `npm i -g opera-browser-cli@latest` would install
52
+ * exactly what is already there:
53
+ *
54
+ * - `0.1.55`, the compatibility launcher that delivered this install,
55
+ * recognised by its `bin/cli.js` marker;
56
+ * - `0.2.0`, the bin-less tombstone that retires the launcher, recognised by
57
+ * version (`0.2.0` is the first release without a bin).
58
+ *
59
+ * What remains at that path — an older `0.1.x`, or a hand-copied file — is what
60
+ * this notice is for.
61
+ */
62
+ export function notifyLegacyPackageInstalled() {
63
+ try {
64
+ const prefix = resolveGlobalPrefix();
65
+ if (prefix === null) {
66
+ return;
67
+ }
68
+ const candidates = [
69
+ path.join(prefix, 'lib', 'node_modules', 'opera-browser-cli'),
70
+ path.join(prefix, 'node_modules', 'opera-browser-cli'),
71
+ ];
72
+ const installed = candidates.find(dir => fs.existsSync(path.join(dir, 'package.json')));
73
+ if (installed === undefined) {
74
+ return;
75
+ }
76
+ if (fs.existsSync(path.join(installed, 'bin', 'cli.js'))) {
77
+ return;
78
+ }
79
+ let version = '';
80
+ try {
81
+ const manifest = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf-8'));
82
+ version = typeof manifest.version === 'string' ? manifest.version : '';
83
+ }
84
+ catch {
85
+ // Unreadable reads as pre-launcher: the status quo this notice is for.
86
+ }
87
+ const [major = 0, minor = 0] = version.split('.').map(part => Number(part));
88
+ if (major > 0 || (major === 0 && minor >= 2)) {
89
+ return;
90
+ }
91
+ warn(`opera-devtools-mcp: legacy opera-browser-cli detected at ${installed}; run: npm i -g opera-browser-cli@latest to migrate.`);
92
+ }
93
+ catch {
94
+ // A notice that cannot be composed must not be the thing that breaks a start.
95
+ }
96
+ }
97
+ /**
98
+ * The recipes that retire the launcher, or the empty string when this CLI was
99
+ * not launched by it — callers join the result into their output and never have
100
+ * to branch.
101
+ */
102
+ export function launcherMigrationNotice() {
103
+ if (process.env[LAUNCHER_ENV] !== '1') {
104
+ return '';
105
+ }
106
+ return [
107
+ 'Notice: this CLI was launched via the compatibility launcher (opera-browser-cli@0.1.55).',
108
+ 'To complete migration, run either:',
109
+ ' npm i -g opera-devtools-mcp@latest opera-browser-cli@latest',
110
+ ' (then optionally: npm rm -g opera-browser-cli)',
111
+ ' — or —',
112
+ ' npm rm -g opera-browser-cli',
113
+ ' npm i -g opera-devtools-mcp@latest',
114
+ ].join('\n');
115
+ }
116
+ //# sourceMappingURL=launcherNotice.js.map
@@ -0,0 +1,297 @@
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
+ * Stop the legacy `opera-browser-cli` HTTP bridge.
9
+ *
10
+ * Before the two packages were merged, `opera-browser-cli` ran an HTTP bridge
11
+ * that held port 9225 (scanning up to 9234) and wrote its identity to
12
+ * `~/.opera-browser-cli/bridge.pid`. A user upgrading to `opera-devtools-mcp`
13
+ * may still have that bridge running, holding a browser and a stdio child of
14
+ * the old MCP server. The new CLI speaks to a Unix-socket daemon instead, so a
15
+ * surviving bridge is a resource leak and a confusing second `opera-browser-cli`
16
+ * in `ps` — this module removes it.
17
+ *
18
+ * Nothing here runs at install time. npm ≥12 blocks install scripts unless the
19
+ * user opts in with a flag nobody types, so the cleanup lives where it always
20
+ * executes: `runLegacyMigrationGuard` is awaited by both entry points — the CLI
21
+ * bin (`opera-browser-cli`) and the MCP bin (`opera-devtools-mcp`), which an MCP
22
+ * client starts on its own, with no CLI invocation anywhere.
23
+ *
24
+ * Two rules govern which process may be signalled, both lifted from the old
25
+ * client's own identity contract: a PID is only trusted when its PID file also
26
+ * records *this* boot (a recycled PID after a reboot must never be killed), and
27
+ * a bridge answering `/health` is trusted by that answer alone. Port probing and
28
+ * the PID file are therefore two independent ways to recognise the same bridge,
29
+ * and neither ever signals a process it has not identified.
30
+ *
31
+ * The legacy CLI starts its bridge `detached`, which makes the bridge a process
32
+ * group leader whose browser and stdio MCP child share its group. Every signal
33
+ * goes to that group: a bridge that ignores SIGTERM is SIGKILLed, and its exit
34
+ * handler — the one that group-kills its children — never runs on SIGKILL.
35
+ */
36
+ import fs from 'node:fs';
37
+ import http from 'node:http';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import process from 'node:process';
41
+ import { getRuntimeHome } from '../daemon/utils.js';
42
+ import { STATE_DIR_NAME } from './branding.js';
43
+ import { notifyLegacyPackageInstalled } from './launcherNotice.js';
44
+ import { getEffectiveHome, isMigrationActive, isProcessAlive, terminateProcess, warn, } from './migrationShared.js';
45
+ /** Port the legacy bridge prefers, and how many consecutive ports it scans. */
46
+ export const LEGACY_DEFAULT_PORT = 9225;
47
+ export const LEGACY_PORT_SCAN_COUNT = 10;
48
+ /** What the legacy bridge names itself in its `/health` payload. */
49
+ const LEGACY_SERVER_NAME = 'opera-browser-cli';
50
+ const HEALTH_TIMEOUT_MS = 2_000;
51
+ /** `~/.opera-browser-cli/bridge.pid` (or `<home>/.opera-browser-cli/bridge.pid`). */
52
+ export function getLegacyPidFilePath(home = os.homedir()) {
53
+ return path.join(home, STATE_DIR_NAME, 'bridge.pid');
54
+ }
55
+ /**
56
+ * The ports the legacy bridge may live on, in preference order. Reads the same
57
+ * `OPERA_CLI_PORT` override the old client honoured.
58
+ */
59
+ export function legacyCandidatePorts() {
60
+ const base = Number.parseInt(process.env.OPERA_CLI_PORT ?? String(LEGACY_DEFAULT_PORT), 10);
61
+ const start = Number.isFinite(base) ? base : LEGACY_DEFAULT_PORT;
62
+ return Array.from({ length: LEGACY_PORT_SCAN_COUNT }, (_, i) => start + i);
63
+ }
64
+ /**
65
+ * The instant this machine booted, in whole minutes since the epoch, or null
66
+ * when the process cannot ask — some sandboxes deny the `uptime` syscall.
67
+ * A null answer means "cannot verify this boot", which is a refusal to signal,
68
+ * not a reason to fail.
69
+ */
70
+ function computeBootMinute(nowMs = Date.now()) {
71
+ try {
72
+ return Math.floor((nowMs - os.uptime() * 1000) / 60_000);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ function readLegacyPidFile(home) {
79
+ try {
80
+ const data = JSON.parse(fs.readFileSync(getLegacyPidFilePath(home), 'utf-8'));
81
+ if (typeof data.pid === 'number' && typeof data.port === 'number') {
82
+ return data;
83
+ }
84
+ return null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function removeLegacyPidFile(home) {
91
+ try {
92
+ fs.rmSync(getLegacyPidFilePath(home), { force: true });
93
+ }
94
+ catch {
95
+ // Best-effort: a PID file we cannot remove is not worth failing the install.
96
+ }
97
+ }
98
+ /** The one notice both stop paths print before signalling a bridge. */
99
+ function legacyStopWarning(pid) {
100
+ return `opera-devtools-mcp: stopping legacy HTTP bridge (pid ${pid}) for migration — any active browser session will be interrupted.`;
101
+ }
102
+ /**
103
+ * Stop the bridge the PID file names, if it names a live one from this boot.
104
+ *
105
+ * This is the CLI's fast path: one file read, no network. It never throws — the
106
+ * CLI calls it before every command, and a migration convenience must not be
107
+ * able to break a working CLI — so anything unexpected reads as "nothing to
108
+ * do". A PID file that names a dead process, or one stamped with a different
109
+ * boot, is cleaned up but never signalled; the port probe is the only way to
110
+ * identify that bridge safely.
111
+ */
112
+ export async function stopLegacyBridge(options = {}) {
113
+ try {
114
+ const home = options.home ?? os.homedir();
115
+ const info = readLegacyPidFile(home);
116
+ if (info === null) {
117
+ return false;
118
+ }
119
+ if (!isProcessAlive(info.pid)) {
120
+ removeLegacyPidFile(home);
121
+ return false;
122
+ }
123
+ const bootMinute = computeBootMinute();
124
+ const fromThisBoot = typeof info.bootMinute === 'number' &&
125
+ bootMinute !== null &&
126
+ Math.abs(info.bootMinute - bootMinute) <= 1;
127
+ if (!fromThisBoot) {
128
+ // An unverifiable PID may be a stranger's after a reboot. Leave the file
129
+ // so the port probe can identify the bridge by its `/health` answer.
130
+ return false;
131
+ }
132
+ warn(legacyStopWarning(info.pid));
133
+ const gone = await terminateProcess(info.pid, { group: true });
134
+ if (gone) {
135
+ removeLegacyPidFile(home);
136
+ }
137
+ return gone;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ /** One HTTP GET, resolving to the body or null on any error or timeout. */
144
+ function httpGetHealth(port) {
145
+ const { promise, resolve } = Promise.withResolvers();
146
+ const req = http.request({
147
+ hostname: '127.0.0.1',
148
+ port,
149
+ path: '/health',
150
+ method: 'GET',
151
+ timeout: HEALTH_TIMEOUT_MS,
152
+ }, res => {
153
+ let body = '';
154
+ res.setEncoding('utf8');
155
+ res.on('data', chunk => {
156
+ body += chunk;
157
+ });
158
+ res.on('end', () => resolve(body));
159
+ res.on('error', () => resolve(null));
160
+ });
161
+ req.on('error', () => resolve(null));
162
+ req.on('timeout', () => {
163
+ req.destroy();
164
+ resolve(null);
165
+ });
166
+ req.end();
167
+ return promise;
168
+ }
169
+ /**
170
+ * Probe the candidate ports and stop every bridge of ours that answers.
171
+ *
172
+ * Used on install, where the bridge's PID file may be missing or root-owned and
173
+ * `/health` is the only remaining identity. A response is ours when its `server`
174
+ * field says so; its `pid` is then the process to stop, falling back to a live
175
+ * PID file that names the same port.
176
+ */
177
+ export async function probeAndStopLegacyBridges(ports, options = {}) {
178
+ try {
179
+ const home = options.home ?? os.homedir();
180
+ const fileInfo = readLegacyPidFile(home);
181
+ const probes = await Promise.all(ports.map(async (port) => {
182
+ const body = await httpGetHealth(port);
183
+ if (body === null) {
184
+ return null;
185
+ }
186
+ try {
187
+ const record = JSON.parse(body);
188
+ if (record.server !== LEGACY_SERVER_NAME) {
189
+ return null;
190
+ }
191
+ return {
192
+ port,
193
+ pid: typeof record.pid === 'number' ? record.pid : 0,
194
+ };
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }));
200
+ let stopped = false;
201
+ for (const probe of probes) {
202
+ if (probe === null) {
203
+ continue;
204
+ }
205
+ const pid = probe.pid > 0
206
+ ? probe.pid
207
+ : fileInfo &&
208
+ fileInfo.port === probe.port &&
209
+ isProcessAlive(fileInfo.pid)
210
+ ? fileInfo.pid
211
+ : null;
212
+ if (pid === null || !isProcessAlive(pid)) {
213
+ continue;
214
+ }
215
+ warn(legacyStopWarning(pid));
216
+ stopped = (await terminateProcess(pid, { group: true })) || stopped;
217
+ }
218
+ if (fileInfo !== null && !isProcessAlive(fileInfo.pid)) {
219
+ removeLegacyPidFile(home);
220
+ }
221
+ return stopped;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ /** The file that records the boot this user's boot-scoped pass already ran in. */
228
+ const BOOT_SCOPED_PASS_FILE = 'legacy-guard';
229
+ /**
230
+ * Claim the once-per-boot pass, and say whether this call owns it.
231
+ *
232
+ * The pass probes the bridge's port window, which is the only way to identify a
233
+ * bridge whose PID file is missing, unreadable (it is mode 0600, so another user
234
+ * cannot read it), or stamped with a boot this process cannot verify. That is
235
+ * network I/O and a possible kill, so it happens at most once per boot per user
236
+ * rather than on every command and every MCP server start.
237
+ *
238
+ * The recorded stamp is the boot instant, exactly as in the bridge's own PID
239
+ * file, so a stale marker cannot outlive a reboot — `/tmp` is not guaranteed to
240
+ * be cleared on macOS, and the marker may also live under `XDG_RUNTIME_DIR`.
241
+ * A marker that cannot be written, or a boot that cannot be computed, claims the
242
+ * pass: a repeated probe is cheap, skipping the cleanup is not.
243
+ */
244
+ function claimBootScopedPass() {
245
+ const marker = path.join(getRuntimeHome(''), BOOT_SCOPED_PASS_FILE);
246
+ let recorded = null;
247
+ try {
248
+ const text = fs.readFileSync(marker, 'utf-8');
249
+ recorded = Number.parseInt(text, 10);
250
+ }
251
+ catch {
252
+ // No marker yet, or one we cannot read: this call owns the pass and
253
+ // rewrites it below.
254
+ }
255
+ const bootMinute = computeBootMinute();
256
+ if (bootMinute !== null &&
257
+ recorded !== null &&
258
+ Number.isFinite(recorded) &&
259
+ Math.abs(recorded - bootMinute) <= 1) {
260
+ return false;
261
+ }
262
+ try {
263
+ fs.mkdirSync(getRuntimeHome(''), { recursive: true });
264
+ fs.writeFileSync(marker, String(bootMinute ?? ''), { mode: 0o600 });
265
+ }
266
+ catch {
267
+ // A pass that cannot be recorded runs again next time; the probe is cheap
268
+ // and the cleanup is not something to skip over a filesystem error.
269
+ }
270
+ return true;
271
+ }
272
+ /**
273
+ * The migration guard both entry points await before they do any work.
274
+ *
275
+ * Split by cost, not by entry point: the PID-file fast path is one read and runs
276
+ * on every command and every MCP server start, so a bridge that left its PID
277
+ * file behind is stopped even by `--version`; the port window is swept once per
278
+ * boot, which covers the orphan the PID file cannot name.
279
+ *
280
+ * Never throws, and never writes to stdout — the CLI's result and the MCP
281
+ * transport both own that stream, and a migration convenience must not be able
282
+ * to break either.
283
+ */
284
+ export async function runLegacyMigrationGuard() {
285
+ if (!isMigrationActive()) {
286
+ return;
287
+ }
288
+ // Under `sudo`, the bridge belongs to the invoking user, not to root.
289
+ const home = getEffectiveHome();
290
+ await stopLegacyBridge({ home });
291
+ if (!claimBootScopedPass()) {
292
+ return;
293
+ }
294
+ await probeAndStopLegacyBridges(legacyCandidatePorts(), { home });
295
+ notifyLegacyPackageInstalled();
296
+ }
297
+ //# sourceMappingURL=legacyBridgeCleanup.js.map