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.
- package/README.md +1 -1
- package/build/src/McpContext.js +78 -41
- package/build/src/McpPage.js +5 -1
- package/build/src/ToolHandler.js +54 -76
- package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
- package/build/src/bin/chrome-devtools.js +56 -124
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +23 -25
- package/build/src/config/browser-options.js +126 -0
- package/build/src/config/category-options.js +81 -0
- package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
- package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
- package/build/src/daemon/client.js +55 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/daemon/utils.js +6 -0
- package/build/src/devtools/DevtoolsUtils.js +27 -21
- package/build/src/formatters/NetworkFormatter.js +5 -2
- package/build/src/index.js +166 -100
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/processors/HeapSnapshotManager.js +12 -0
- package/build/src/telemetry/ClearcutLogger.js +19 -6
- package/build/src/telemetry/transformation.js +4 -0
- package/build/src/telemetry/types.js +4 -0
- package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
- package/build/src/third_party/bundled-packages.json +3 -3
- package/build/src/third_party/devtools-formatter-worker.js +23 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
- package/build/src/third_party/index.js +15460 -14256
- package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
- package/build/src/tools/ToolDefinition.js +15 -0
- package/build/src/tools/categories.js +0 -6
- package/build/src/tools/comments.js +90 -0
- package/build/src/tools/console.js +1 -1
- package/build/src/tools/emulation.js +1 -1
- package/build/src/tools/extensions.js +1 -1
- package/build/src/tools/memory.js +60 -6
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/pages.js +26 -15
- package/build/src/tools/performance.js +4 -3
- package/build/src/tools/screencast.js +3 -2
- package/build/src/tools/screenshot.js +39 -8
- package/build/src/tools/script.js +17 -4
- package/build/src/tools/slim/tools.js +41 -33
- package/build/src/tools/snapshot.js +1 -1
- package/build/src/tools/tools.js +2 -0
- package/build/src/utils/WaitForHelper.js +12 -1
- package/build/src/utils/bytes.js +105 -0
- package/build/src/utils/url.js +79 -0
- package/build/src/version.js +1 -1
- package/package.json +29 -8
- 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
|