opera-devtools-mcp 0.7.0 → 0.8.1
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/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- 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/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- package/build/src/bin/opera-devtools.js +0 -10
|
@@ -0,0 +1,130 @@
|
|
|
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 { LoggingMessageNotificationSchema, PipeTransport, } from '../third_party/index.js';
|
|
8
|
+
import { logger, puppeteerLogger } from '../utils/logger.js';
|
|
9
|
+
import { daemonExitMessage } from './daemonLog.js';
|
|
10
|
+
/** A streamed chunk frame, distinguished from the final response frame. */
|
|
11
|
+
function isLogFrame(value) {
|
|
12
|
+
if (typeof value !== 'object' || value === null || !('log' in value)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return typeof value.log === 'string';
|
|
16
|
+
}
|
|
17
|
+
/** The live chunk sinks, keyed by the streaming token of their request. */
|
|
18
|
+
const sinks = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* The streaming token a notification carries, if any.
|
|
21
|
+
*
|
|
22
|
+
* `_meta` is the protocol's own slot for per-request metadata of this kind
|
|
23
|
+
* (`RequestMetaSchema` is a loose object, so an extra key is legal on both the
|
|
24
|
+
* request and the notification), and a notification without one belongs to a
|
|
25
|
+
* caller that did not ask for streaming.
|
|
26
|
+
*/
|
|
27
|
+
function streamTokenOf(params) {
|
|
28
|
+
const meta = params._meta;
|
|
29
|
+
if (typeof meta !== 'object' || meta === null) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const token = meta.streamToken;
|
|
33
|
+
return typeof token === 'string' ? token : undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Forward `notifications/message` chunks to the sink of the request they
|
|
37
|
+
* belong to.
|
|
38
|
+
*
|
|
39
|
+
* `data` is a plain string (see `makeLogCallback`); anything else is some other
|
|
40
|
+
* logger's notification and is ignored rather than stringified into the user's
|
|
41
|
+
* terminal.
|
|
42
|
+
*/
|
|
43
|
+
export function attachLogForwarding(client) {
|
|
44
|
+
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
|
45
|
+
const params = notification.params;
|
|
46
|
+
if (typeof params !== 'object' ||
|
|
47
|
+
params === null ||
|
|
48
|
+
!('data' in params) ||
|
|
49
|
+
typeof params.data !== 'string') {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const token = streamTokenOf(params);
|
|
53
|
+
if (token === undefined) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
sinks.get(token)?.(params.data);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run `work` with the sink for `token` installed, and always remove it again —
|
|
61
|
+
* a sink left behind would push chunks from a *later* request onto a closed
|
|
62
|
+
* socket. Only this token's entry is removed, so a request that finishes never
|
|
63
|
+
* takes another request's streaming down with it.
|
|
64
|
+
*/
|
|
65
|
+
export async function withLogSink(token, sink, work) {
|
|
66
|
+
sinks.set(token, sink);
|
|
67
|
+
try {
|
|
68
|
+
return await work();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
sinks.delete(token);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read one response off one socket connection, streaming-aware: client side.
|
|
76
|
+
*
|
|
77
|
+
* With `onLog`, the request is marked `stream` and the connection stays open
|
|
78
|
+
* for the whole run: each `{log}` frame is handed to `onLog` as it arrives, and
|
|
79
|
+
* the promise settles on the final `DaemonResponse`. The timeout is re-armed by
|
|
80
|
+
* every chunk, so a long call that is actively producing output is never killed
|
|
81
|
+
* for being slow — only for going quiet.
|
|
82
|
+
*
|
|
83
|
+
* Without `onLog`, the original one-message-one-response protocol applies and
|
|
84
|
+
* the first frame is the answer, byte for byte as upstream's client reads it.
|
|
85
|
+
*/
|
|
86
|
+
export function requestOverSocket(options) {
|
|
87
|
+
const { socket, command, timeout, sessionId, onLog } = options;
|
|
88
|
+
// Only a tool call can produce chunks, so a `stop` or `status` that is
|
|
89
|
+
// handed a sink anyway is still sent on the original protocol.
|
|
90
|
+
const message = onLog !== undefined && command.method === 'invoke_tool'
|
|
91
|
+
? { ...command, stream: true }
|
|
92
|
+
: command;
|
|
93
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
94
|
+
let timer;
|
|
95
|
+
const arm = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
socket.destroy();
|
|
99
|
+
reject(new Error('Timeout waiting for daemon response'));
|
|
100
|
+
}, timeout);
|
|
101
|
+
};
|
|
102
|
+
arm();
|
|
103
|
+
const transport = new PipeTransport(socket, socket, puppeteerLogger);
|
|
104
|
+
transport.onmessage = (frame) => {
|
|
105
|
+
logger?.('onmessage', frame);
|
|
106
|
+
const parsed = JSON.parse(frame);
|
|
107
|
+
// A chunk frame is not the answer: hand it over and keep waiting.
|
|
108
|
+
if (onLog !== undefined && isLogFrame(parsed)) {
|
|
109
|
+
arm();
|
|
110
|
+
onLog(parsed.log);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(parsed);
|
|
115
|
+
};
|
|
116
|
+
socket.on('error', error => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
logger?.('Socket error:', error);
|
|
119
|
+
reject(error);
|
|
120
|
+
});
|
|
121
|
+
socket.on('close', () => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
logger?.('Socket closed:');
|
|
124
|
+
reject(new Error(daemonExitMessage(sessionId)));
|
|
125
|
+
});
|
|
126
|
+
logger?.('Sending message', message);
|
|
127
|
+
transport.send(JSON.stringify(message));
|
|
128
|
+
return promise;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=daemonStreaming.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { operaAiTimeoutMs } from './streamingTools.js';
|
|
8
|
+
/**
|
|
9
|
+
* Run one tool through the daemon's MCP client.
|
|
10
|
+
*
|
|
11
|
+
* `_meta` is the only per-request slot the MCP round trip preserves, which is
|
|
12
|
+
* what makes it the streaming token's carrier: the server echoes it on every
|
|
13
|
+
* `notifications/message` the tool emits, so a chunk can be routed back to the
|
|
14
|
+
* connection whose request produced it (`opera/daemonStreaming.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export async function callDaemonTool(client, request) {
|
|
17
|
+
const timeout = operaAiTimeoutMs(request.tool);
|
|
18
|
+
return await client.callTool({
|
|
19
|
+
name: request.tool,
|
|
20
|
+
arguments: request.args ?? {},
|
|
21
|
+
...(request.streamToken === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: { _meta: { streamToken: request.streamToken } }),
|
|
24
|
+
}, undefined, timeout === undefined ? undefined : { timeout });
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=daemonToolCall.js.map
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
* Finding an installed browser. Ported from opera-browser-cli's `src/detect.ts`
|
|
9
|
+
* (Phase 1b). Opera Neon is preferred because it is the only build with the
|
|
10
|
+
* full Opera AI tool set; a plain Opera still gives `chat`. Anything else means
|
|
11
|
+
* the AI commands cannot work, which the caller reports rather than discovering
|
|
12
|
+
* halfway through a command.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
export function neonCandidatePaths(platform = process.platform, home = '', env = process.env) {
|
|
17
|
+
if (platform === 'darwin') {
|
|
18
|
+
// `/Applications` is a literal macOS path; the `~/Applications` root is
|
|
19
|
+
// joined so it carries this host's separators, matching what a caller
|
|
20
|
+
// locating the same install with `path.join` builds. The pair is dropped
|
|
21
|
+
// for an empty home, where it would be the absolute paths over again.
|
|
22
|
+
return [
|
|
23
|
+
'/Applications/Opera Neon Developer.app/Contents/MacOS/Opera',
|
|
24
|
+
'/Applications/Opera Neon.app/Contents/MacOS/Opera',
|
|
25
|
+
...(home === ''
|
|
26
|
+
? []
|
|
27
|
+
: [
|
|
28
|
+
join(home, 'Applications', 'Opera Neon Developer.app', 'Contents', 'MacOS', 'Opera'),
|
|
29
|
+
join(home, 'Applications', 'Opera Neon.app', 'Contents', 'MacOS', 'Opera'),
|
|
30
|
+
]),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
if (platform === 'win32') {
|
|
34
|
+
const localAppData = env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
|
|
35
|
+
const programFiles = env.PROGRAMFILES ?? 'C:\\Program Files';
|
|
36
|
+
return [
|
|
37
|
+
`${localAppData}\\Programs\\Opera Neon Developer\\opera.exe`,
|
|
38
|
+
`${programFiles}\\Opera Neon Developer\\opera.exe`,
|
|
39
|
+
`${localAppData}\\Programs\\Opera Neon\\opera.exe`,
|
|
40
|
+
`${programFiles}\\Opera Neon\\opera.exe`,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
// Opera Neon does not ship for Linux.
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
export function operaCandidatePaths(platform = process.platform, home = '', env = process.env) {
|
|
47
|
+
if (platform === 'darwin') {
|
|
48
|
+
return [
|
|
49
|
+
'/Applications/Opera GX.app/Contents/MacOS/Opera',
|
|
50
|
+
'/Applications/Opera.app/Contents/MacOS/Opera',
|
|
51
|
+
...(home === ''
|
|
52
|
+
? []
|
|
53
|
+
: [
|
|
54
|
+
join(home, 'Applications', 'Opera GX.app', 'Contents', 'MacOS', 'Opera'),
|
|
55
|
+
join(home, 'Applications', 'Opera.app', 'Contents', 'MacOS', 'Opera'),
|
|
56
|
+
]),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
if (platform === 'win32') {
|
|
60
|
+
const localAppData = env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
|
|
61
|
+
const programFiles = env.PROGRAMFILES ?? 'C:\\Program Files';
|
|
62
|
+
return [
|
|
63
|
+
`${localAppData}\\Programs\\Opera GX\\opera.exe`,
|
|
64
|
+
`${localAppData}\\Programs\\Opera\\opera.exe`,
|
|
65
|
+
`${programFiles}\\Opera GX\\opera.exe`,
|
|
66
|
+
`${programFiles}\\Opera\\opera.exe`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Which Opera build an install path belongs to, judged by the directory the
|
|
73
|
+
* executable sits in. Matching whole install-directory names — rather than a
|
|
74
|
+
* substring anywhere in the path — keeps an unrelated directory that merely
|
|
75
|
+
* mentions a build (`/tmp/GX-tests/opera`) from being misreported.
|
|
76
|
+
*/
|
|
77
|
+
export function browserDisplayName(binPath) {
|
|
78
|
+
const dirs = binPath
|
|
79
|
+
.split(/[\\/]/)
|
|
80
|
+
.slice(0, -1)
|
|
81
|
+
.map(segment => segment.replace(/\.app$/, ''));
|
|
82
|
+
if (dirs.includes('Opera Neon Developer')) {
|
|
83
|
+
return 'Opera Neon Developer';
|
|
84
|
+
}
|
|
85
|
+
if (dirs.includes('Opera Neon')) {
|
|
86
|
+
return 'Opera Neon';
|
|
87
|
+
}
|
|
88
|
+
if (dirs.includes('Opera GX')) {
|
|
89
|
+
return 'Opera GX';
|
|
90
|
+
}
|
|
91
|
+
return 'Opera';
|
|
92
|
+
}
|
|
93
|
+
/** Every Opera install we can find, Neon first, each path reported once. */
|
|
94
|
+
export function detectBrowsers(platform = process.platform, home = '', exists = existsSync, env = process.env) {
|
|
95
|
+
const neon = new Set(neonCandidatePaths(platform, home, env));
|
|
96
|
+
const found = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const path of [...neon, ...operaCandidatePaths(platform, home, env)]) {
|
|
99
|
+
// A caller may pass a home whose `Applications` root is the same directory
|
|
100
|
+
// as `/Applications`; without this check the same install would be reported
|
|
101
|
+
// twice.
|
|
102
|
+
if (seen.has(path) || !exists(path)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
seen.add(path);
|
|
106
|
+
found.push({ path, name: browserDisplayName(path), isNeon: neon.has(path) });
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
/** The browser to use when nobody has said which. */
|
|
111
|
+
export function detectBrowser(platform = process.platform, home = '', exists = existsSync, env = process.env) {
|
|
112
|
+
return detectBrowsers(platform, home, exists, env)[0] ?? null;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
* `doctor` — the five checks that translate onto the daemon model, and the two
|
|
9
|
+
* repairs that need no decision from the user.
|
|
10
|
+
*
|
|
11
|
+
* Ported from opera-browser-cli's `src/cli.ts` `runDoctorChecks` /
|
|
12
|
+
* `runDoctorFixes`, filtered: the source's `bridge` check is subsumed by the
|
|
13
|
+
* daemon check here, its `mcp` check is about a binary this package *is*, and
|
|
14
|
+
* its `hooks` check is dropped with the hooks themselves. What remains is the
|
|
15
|
+
* configuration, the executable, the profile lock, the daemon, and the log.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here ever fails the command: a failing check is a `fail` row, and the
|
|
18
|
+
* process still exits 0. The point of `doctor` is to report, not to gate.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
|
|
21
|
+
import { sendCommand } from '../daemon/client.js';
|
|
22
|
+
import { isDaemonRunning } from '../daemon/utils.js';
|
|
23
|
+
import { VERSION } from '../version.js';
|
|
24
|
+
import { CLI_BIN_NAME } from './branding.js';
|
|
25
|
+
import { encode, renderHelp, renderOutput } from './cliOutput.js';
|
|
26
|
+
import { autoConfigure } from './config.js';
|
|
27
|
+
import { readExitReason } from './daemonLifecycle.js';
|
|
28
|
+
import { getDaemonLogPath, getPreviousDaemonLogPath } from './daemonLog.js';
|
|
29
|
+
import { findUnknownConfigKeys, getConfigFile, readConfigFile, } from './envConfig.js';
|
|
30
|
+
import { launcherMigrationNotice } from './launcherNotice.js';
|
|
31
|
+
import { inspectProfileLock, probeDevToolsEndpoint, readDevToolsPort, } from './profile.js';
|
|
32
|
+
/** Logs above this are rotated by `doctor --fix`. */
|
|
33
|
+
const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
34
|
+
export function formatBytes(n) {
|
|
35
|
+
if (n < 1024) {
|
|
36
|
+
return `${n} B`;
|
|
37
|
+
}
|
|
38
|
+
if (n < 1024 * 1024) {
|
|
39
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
40
|
+
}
|
|
41
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
42
|
+
}
|
|
43
|
+
export function fileContainsMarker(path, marker) {
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(path, 'utf-8').includes(marker);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The config file: absent is a warning, unknown keys are a warning, else ok. */
|
|
55
|
+
function checkConfig() {
|
|
56
|
+
const configFile = getConfigFile();
|
|
57
|
+
if (!existsSync(configFile)) {
|
|
58
|
+
return {
|
|
59
|
+
name: 'config',
|
|
60
|
+
status: 'warn',
|
|
61
|
+
detail: `${configFile} not found — run \`${CLI_BIN_NAME} setup\``,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const config = readConfigFile(configFile);
|
|
65
|
+
const count = Object.keys(config).length;
|
|
66
|
+
const unknown = findUnknownConfigKeys(config);
|
|
67
|
+
if (unknown.length > 0) {
|
|
68
|
+
// A typo'd key is silently ignored at load time and looks perfectly correct
|
|
69
|
+
// in the file, so it has to be called out here or never.
|
|
70
|
+
const described = unknown
|
|
71
|
+
.map(u => u.suggestion ? `${u.key} (did you mean ${u.suggestion}?)` : u.key)
|
|
72
|
+
.join(', ');
|
|
73
|
+
return {
|
|
74
|
+
name: 'config',
|
|
75
|
+
status: 'warn',
|
|
76
|
+
detail: `${configFile} — unrecognised key${unknown.length === 1 ? '' : 's'}: ${described}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name: 'config',
|
|
81
|
+
status: 'ok',
|
|
82
|
+
detail: `${configFile} (${count} var${count === 1 ? '' : 's'} set)`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** The Opera executable, unless an attach URL makes it unnecessary. */
|
|
86
|
+
function checkExecutable() {
|
|
87
|
+
const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH;
|
|
88
|
+
const browserUrl = process.env.OPERA_CLI_BROWSER_URL;
|
|
89
|
+
if (browserUrl) {
|
|
90
|
+
return {
|
|
91
|
+
name: 'executable',
|
|
92
|
+
status: 'ok',
|
|
93
|
+
detail: `OPERA_CLI_BROWSER_URL=${browserUrl} (skipping executable check)`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!execPath) {
|
|
97
|
+
return {
|
|
98
|
+
name: 'executable',
|
|
99
|
+
status: 'warn',
|
|
100
|
+
detail: 'OPERA_CLI_EXECUTABLE_PATH not set — Opera AI commands will fail',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!existsSync(execPath)) {
|
|
104
|
+
return {
|
|
105
|
+
name: 'executable',
|
|
106
|
+
status: 'fail',
|
|
107
|
+
detail: `OPERA_CLI_EXECUTABLE_PATH=${execPath} does not exist`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { name: 'executable', status: 'ok', detail: execPath };
|
|
111
|
+
}
|
|
112
|
+
/** The persistent profile: free, in use and attachable, or in use and locked. */
|
|
113
|
+
async function checkProfile() {
|
|
114
|
+
const profileDir = process.env.OPERA_CLI_USER_DATA_DIR;
|
|
115
|
+
if (!profileDir) {
|
|
116
|
+
return {
|
|
117
|
+
name: 'profile',
|
|
118
|
+
status: 'ok',
|
|
119
|
+
detail: 'isolated (no persistent profile configured)',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync(profileDir)) {
|
|
123
|
+
return {
|
|
124
|
+
name: 'profile',
|
|
125
|
+
status: 'ok',
|
|
126
|
+
detail: `${profileDir} (will be created on first launch)`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const lock = inspectProfileLock(profileDir);
|
|
130
|
+
if (lock.state === 'free') {
|
|
131
|
+
return { name: 'profile', status: 'ok', detail: `${profileDir} (free)` };
|
|
132
|
+
}
|
|
133
|
+
const port = readDevToolsPort(profileDir);
|
|
134
|
+
const live = port !== null ? await probeDevToolsEndpoint(port) : null;
|
|
135
|
+
if (live) {
|
|
136
|
+
return {
|
|
137
|
+
name: 'profile',
|
|
138
|
+
status: 'ok',
|
|
139
|
+
detail: `in use by ${live.browser}, attachable on port ${port}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
name: 'profile',
|
|
144
|
+
status: 'warn',
|
|
145
|
+
detail: `in use${lock.pid ? ` by pid ${lock.pid}` : ''} with no debugging port — a separate profile will be used`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** The daemon: not running, running, or running a different version. */
|
|
149
|
+
async function checkDaemon(sessionId) {
|
|
150
|
+
if (!isDaemonRunning(sessionId)) {
|
|
151
|
+
const reason = readExitReason(sessionId);
|
|
152
|
+
return {
|
|
153
|
+
name: 'daemon',
|
|
154
|
+
status: 'warn',
|
|
155
|
+
detail: reason
|
|
156
|
+
? `not running (last exit: ${reason})`
|
|
157
|
+
: 'not running (will auto-start on first command)',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const response = await sendCommand({ method: 'status' }, sessionId);
|
|
162
|
+
if (!response.success) {
|
|
163
|
+
return {
|
|
164
|
+
name: 'daemon',
|
|
165
|
+
status: 'fail',
|
|
166
|
+
detail: `running but ${String(response.error)}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const data = JSON.parse(response.result);
|
|
170
|
+
if (data.version !== VERSION) {
|
|
171
|
+
return {
|
|
172
|
+
name: 'daemon',
|
|
173
|
+
status: 'warn',
|
|
174
|
+
detail: `running ${data.version}, but this CLI is ${VERSION} — restarts automatically on next command`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
name: 'daemon',
|
|
179
|
+
status: 'ok',
|
|
180
|
+
detail: `running ${data.version}, pid ${data.pid}, socket ${data.socketPath}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
name: 'daemon',
|
|
186
|
+
status: 'fail',
|
|
187
|
+
detail: `running but not answering: ${error instanceof Error ? error.message : String(error)}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** The daemon log, which is where a daemon that died without a reason explains itself. */
|
|
192
|
+
function checkLogs(sessionId) {
|
|
193
|
+
const logFile = getDaemonLogPath(sessionId);
|
|
194
|
+
if (!existsSync(logFile)) {
|
|
195
|
+
return {
|
|
196
|
+
name: 'logs',
|
|
197
|
+
status: 'warn',
|
|
198
|
+
detail: `${logFile} not yet created`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const size = statSync(logFile).size;
|
|
203
|
+
return {
|
|
204
|
+
name: 'logs',
|
|
205
|
+
status: 'ok',
|
|
206
|
+
detail: `${logFile} (${formatBytes(size)})`,
|
|
207
|
+
bytes: size,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return {
|
|
212
|
+
name: 'logs',
|
|
213
|
+
status: 'warn',
|
|
214
|
+
detail: `${logFile} exists but cannot stat`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export async function runDoctorChecks(sessionId) {
|
|
219
|
+
return [
|
|
220
|
+
checkConfig(),
|
|
221
|
+
checkExecutable(),
|
|
222
|
+
await checkProfile(),
|
|
223
|
+
await checkDaemon(sessionId),
|
|
224
|
+
checkLogs(sessionId),
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Rotate the daemon log out of the way, one generation.
|
|
229
|
+
*
|
|
230
|
+
* The daemon does this itself on startup (`openDaemonLog`), but a session that
|
|
231
|
+
* stays up for weeks never restarts, so `--fix` does it explicitly. Renaming a
|
|
232
|
+
* file the daemon still holds open is safe on POSIX: the daemon keeps writing
|
|
233
|
+
* to the renamed inode until its next open. On Windows the rename would fail
|
|
234
|
+
* against a live handle, so there it is skipped and reported.
|
|
235
|
+
*/
|
|
236
|
+
function rotateDaemonLog(sessionId) {
|
|
237
|
+
const logFile = getDaemonLogPath(sessionId);
|
|
238
|
+
if (process.platform === 'win32' && isDaemonRunning(sessionId)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
renameSync(logFile, getPreviousDaemonLogPath(sessionId));
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Repair what can be repaired mechanically. Anything needing a decision — an
|
|
251
|
+
* install, a config edit — is reported, never done on the user's behalf.
|
|
252
|
+
*/
|
|
253
|
+
export async function runDoctorFixes(checks, sessionId) {
|
|
254
|
+
const done = [];
|
|
255
|
+
const config = checks.find(check => check.name === 'config');
|
|
256
|
+
if (config && config.detail.includes('not found')) {
|
|
257
|
+
const result = autoConfigure();
|
|
258
|
+
if (result.status === 'configured') {
|
|
259
|
+
done.push(`wrote a config for ${result.browser.name}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const logs = checks.find(check => check.name === 'logs');
|
|
263
|
+
if (logs?.bytes !== undefined && logs.bytes >= LOG_ROTATE_BYTES) {
|
|
264
|
+
done.push(rotateDaemonLog(sessionId)
|
|
265
|
+
? 'rotated the daemon log'
|
|
266
|
+
: 'could not rotate the daemon log (it is held open)');
|
|
267
|
+
}
|
|
268
|
+
return done;
|
|
269
|
+
}
|
|
270
|
+
function summarize(checks) {
|
|
271
|
+
return {
|
|
272
|
+
ok: checks.filter(check => check.status === 'ok').length,
|
|
273
|
+
warn: checks.filter(check => check.status === 'warn').length,
|
|
274
|
+
fail: checks.filter(check => check.status === 'fail').length,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function renderChecks(checks) {
|
|
278
|
+
return `checks[${checks.length}]:\n${checks
|
|
279
|
+
.map(check => ` ${check.name}: ${check.status} (${check.detail})`)
|
|
280
|
+
.join('\n')}`;
|
|
281
|
+
}
|
|
282
|
+
export async function handleDoctor(args, sessionId) {
|
|
283
|
+
if (args.includes('--fix')) {
|
|
284
|
+
const applied = await runDoctorFixes(await runDoctorChecks(sessionId), sessionId);
|
|
285
|
+
const after = await runDoctorChecks(sessionId);
|
|
286
|
+
const summary = { fixed: applied.length, ...summarize(after) };
|
|
287
|
+
return renderOutput([
|
|
288
|
+
await encode({ doctor: summary }),
|
|
289
|
+
applied.length > 0
|
|
290
|
+
? `fixed[${applied.length}]:\n${applied.map(line => ` ${line}`).join('\n')}`
|
|
291
|
+
: 'fixed: nothing needed repairing',
|
|
292
|
+
renderChecks(after),
|
|
293
|
+
launcherMigrationNotice(),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
const checks = await runDoctorChecks(sessionId);
|
|
297
|
+
const help = [];
|
|
298
|
+
if (checks.some(check => check.name === 'config' && check.status !== 'ok')) {
|
|
299
|
+
help.push(`Run \`${CLI_BIN_NAME} setup\` to write a config file`);
|
|
300
|
+
}
|
|
301
|
+
if (checks.some(check => check.name === 'executable' && check.status !== 'ok')) {
|
|
302
|
+
help.push(`Run \`${CLI_BIN_NAME} setup\` to detect Opera, or set OPERA_CLI_EXECUTABLE_PATH`);
|
|
303
|
+
}
|
|
304
|
+
if (checks.some(check => check.name === 'profile' && check.status === 'warn')) {
|
|
305
|
+
help.push('A profile in use with no debugging port cannot be attached to — the CLI will use a separate one');
|
|
306
|
+
}
|
|
307
|
+
if (checks.some(check => check.name === 'daemon' && check.status === 'fail')) {
|
|
308
|
+
help.push(`Run \`${CLI_BIN_NAME} logs\` to see why the daemon is unhealthy`);
|
|
309
|
+
}
|
|
310
|
+
return renderOutput([
|
|
311
|
+
await encode({ doctor: summarize(checks) }),
|
|
312
|
+
renderChecks(checks),
|
|
313
|
+
help.length > 0 ? renderHelp(help) : '',
|
|
314
|
+
launcherMigrationNotice(),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
//# sourceMappingURL=doctor.js.map
|