@mearl/client 2.19.2 → 2.19.3
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 +12 -3
- package/dist/check.js +16 -4
- package/dist/cli.js +20 -1
- package/dist/commands.d.ts +73 -12
- package/dist/commands.js +198 -8
- package/dist/generated-browser-action-protocol.d.ts +8 -4
- package/dist/generated-browser-action-protocol.js +14 -5
- package/dist/plugin.d.ts +5 -2
- package/dist/plugin.js +66 -21
- package/dist/pluginPageBundle.d.ts +1 -0
- package/dist/pluginPageBundle.js +7 -1
- package/dist/pluginRegistry.d.ts +5 -0
- package/dist/pluginRegistry.js +28 -0
- package/dist/pluginSource.d.ts +2 -0
- package/dist/pluginSource.js +8 -0
- package/dist/socket.js +180 -44
- package/dist/unified.js +2 -1
- package/package.json +3 -3
package/dist/plugin.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { readFileSync
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, resolve } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
import { COMMAND_MAP } from './commands.js';
|
|
6
6
|
import { findOfficialPlugin } from './officialPluginCatalog.js';
|
|
7
7
|
import { ensureOfficialPluginRegistered, } from './officialPluginInstaller.js';
|
|
8
|
-
import { findPluginRegistration, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
|
|
8
|
+
import { findPluginRegistration, pluginRegistrationNeedsRefresh, registerPlugin, pluginDirectory, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
|
|
9
|
+
import { pluginFileVersion } from './pluginSource.js';
|
|
9
10
|
import { describePythonPlugin, runPythonPluginCommand } from './pythonPlugin.js';
|
|
10
11
|
export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, } from './pluginRegistry.js';
|
|
11
12
|
export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, } from './officialPluginCatalog.js';
|
|
@@ -67,14 +68,61 @@ function missingPluginEntryMessage(entry, pluginName) {
|
|
|
67
68
|
`卸载命令:npx @mearl/setup plugin uninstall ${name}`,
|
|
68
69
|
].join('\n');
|
|
69
70
|
}
|
|
71
|
+
const registrationRefreshes = new Map();
|
|
72
|
+
/** 工具列表仅检查入口;页面资源只在打开对应工具时检查。 */
|
|
73
|
+
export async function refreshRegisteredPlugin(name, includePage = false) {
|
|
74
|
+
const key = `${pluginDirectory()}/${name}`;
|
|
75
|
+
const pending = registrationRefreshes.get(key);
|
|
76
|
+
if (pending) {
|
|
77
|
+
await pending;
|
|
78
|
+
return refreshRegisteredPlugin(name, includePage);
|
|
79
|
+
}
|
|
80
|
+
const registration = findPluginRegistration(name);
|
|
81
|
+
if (!registration)
|
|
82
|
+
return undefined;
|
|
83
|
+
const entryChanged = pluginRegistrationNeedsRefresh(registration);
|
|
84
|
+
if (!entryChanged && (!includePage || !pluginRegistrationNeedsRefresh(registration, true))) {
|
|
85
|
+
return registration;
|
|
86
|
+
}
|
|
87
|
+
const refresh = (async () => {
|
|
88
|
+
const entryVersion = pluginFileVersion(registration.entry);
|
|
89
|
+
const loaded = entryChanged
|
|
90
|
+
? await loadPlugin(registration.entry, {
|
|
91
|
+
options: {},
|
|
92
|
+
invoke: async () => {
|
|
93
|
+
throw new Error('注册时不能执行浏览器操作');
|
|
94
|
+
},
|
|
95
|
+
progress: () => {
|
|
96
|
+
throw new Error('注册时不能报告执行进度');
|
|
97
|
+
},
|
|
98
|
+
}, name)
|
|
99
|
+
: registration;
|
|
100
|
+
// 异步加载期间其他进程可能卸载或重新绑定同名插件。
|
|
101
|
+
const current = findPluginRegistration(name);
|
|
102
|
+
if (JSON.stringify(current) !== JSON.stringify(registration))
|
|
103
|
+
return current;
|
|
104
|
+
if (pluginFileVersion(registration.entry) !== entryVersion) {
|
|
105
|
+
throw new PluginError('PLUGIN_SOURCE_CHANGED', `插件更新进行中,请重新打开工具: ${name}`);
|
|
106
|
+
}
|
|
107
|
+
registerPlugin(name, registration.entry, loaded.description, {
|
|
108
|
+
toolbox: loaded.toolbox,
|
|
109
|
+
...(registration.managedBy ? { managedBy: registration.managedBy } : {}),
|
|
110
|
+
});
|
|
111
|
+
return findPluginRegistration(name);
|
|
112
|
+
})();
|
|
113
|
+
registrationRefreshes.set(key, refresh);
|
|
114
|
+
try {
|
|
115
|
+
return await refresh;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
registrationRefreshes.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
70
121
|
/** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
|
|
71
122
|
export async function loadPlugin(entry, context, pluginName) {
|
|
72
123
|
let entryVersion;
|
|
73
124
|
try {
|
|
74
|
-
|
|
75
|
-
if (!stat.isFile())
|
|
76
|
-
throw new Error('not a file');
|
|
77
|
-
entryVersion = `${stat.mtimeMs}-${stat.size}`;
|
|
125
|
+
entryVersion = pluginFileVersion(entry);
|
|
78
126
|
}
|
|
79
127
|
catch {
|
|
80
128
|
throw new PluginError('PLUGIN_ENTRY_MISSING', missingPluginEntryMessage(entry, pluginName));
|
|
@@ -87,11 +135,15 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
87
135
|
let toolbox;
|
|
88
136
|
const app = Object.freeze({
|
|
89
137
|
options: Object.freeze({ ...context.options }),
|
|
90
|
-
command(signature, handler, description = '') {
|
|
138
|
+
command(signature, handler, description = '', examples = []) {
|
|
91
139
|
if (!registering)
|
|
92
140
|
throw new Error('命令只能在入口注册阶段声明');
|
|
93
141
|
const [name, ...args] = signature.trim().split(/\s+/);
|
|
94
|
-
if (!/^[a-z][a-z0-9-]*$/.test(name) ||
|
|
142
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name) ||
|
|
143
|
+
commands.has(name) ||
|
|
144
|
+
typeof handler !== 'function' ||
|
|
145
|
+
!Array.isArray(examples) ||
|
|
146
|
+
examples.some((example) => typeof example !== 'string' || !example.trim())) {
|
|
95
147
|
throw new Error(`无效或重复的命令: ${signature}`);
|
|
96
148
|
}
|
|
97
149
|
const command = new Command(name).description(description);
|
|
@@ -104,7 +156,7 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
104
156
|
const names = command.registeredArguments.map(argument => argument.name());
|
|
105
157
|
if (new Set(names).size !== names.length)
|
|
106
158
|
throw new Error(`参数名称重复: ${signature}`);
|
|
107
|
-
commands.set(name, { command, signature, handler });
|
|
159
|
+
commands.set(name, { command, signature, handler, examples: [...examples] });
|
|
108
160
|
},
|
|
109
161
|
invoke(action, payload = {}, options = {}) {
|
|
110
162
|
if (!executing)
|
|
@@ -175,20 +227,13 @@ export async function loadPlugin(entry, context, pluginName) {
|
|
|
175
227
|
},
|
|
176
228
|
help(namespace, name) {
|
|
177
229
|
const selected = name ? [find(name)] : [...commands.values()];
|
|
178
|
-
const
|
|
179
|
-
const input = Object.fromEntries(example.registeredArguments.map(argument => [
|
|
180
|
-
argument.name(),
|
|
181
|
-
argument.variadic ? [`<${argument.name()}>`] : `<${argument.name()}>`,
|
|
182
|
-
]));
|
|
230
|
+
const examples = selected.flatMap(({ examples }) => examples);
|
|
183
231
|
return [
|
|
232
|
+
'用法:',
|
|
184
233
|
...selected.map(({ command, signature }) => ` ${namespace} ${signature} ${command.description()}`),
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
'常用选项: --browser --timeout --compact --output',
|
|
189
|
-
'连接或传输排障运行 mearl check --help。',
|
|
190
|
-
'插件选项: --name=value 传入字符串业务字段;--all 传入 all=true;--verbose; -h, --help; -v, --version',
|
|
191
|
-
'同名宿主选项及非字符串字段使用 --payload;业务字段与校验规则见对应 skill。',
|
|
234
|
+
...(examples.length
|
|
235
|
+
? ['', '示例:', ...examples.map(example => ` ${namespace} ${example}`)]
|
|
236
|
+
: []),
|
|
192
237
|
].join('\n');
|
|
193
238
|
},
|
|
194
239
|
async run(positionals, payload, namedInput = {}) {
|
package/dist/pluginPageBundle.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { pluginFileVersion } from './pluginSource.js';
|
|
4
5
|
export const PLUGIN_PAGE_CHUNK_BYTES = 384 * 1024;
|
|
5
6
|
const MAX_PAGE_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
6
7
|
const MAX_PAGE_BUNDLE_BYTES = 24 * 1024 * 1024;
|
|
@@ -578,6 +579,7 @@ export function buildPluginPageBundle(entry, pageEntry) {
|
|
|
578
579
|
const root = realpathSync(pluginRoot(entry));
|
|
579
580
|
let sourceBytes = 0;
|
|
580
581
|
const countedFiles = new Set();
|
|
582
|
+
const sources = {};
|
|
581
583
|
const resolveLocalFile = (reference, fromFile) => {
|
|
582
584
|
const cleanReference = reference.trim().split(/[?#]/, 1)[0];
|
|
583
585
|
if (!cleanReference || isEmbeddedReference(cleanReference))
|
|
@@ -585,14 +587,17 @@ export function buildPluginPageBundle(entry, pageEntry) {
|
|
|
585
587
|
if (isRemoteReference(cleanReference) || isAbsolute(cleanReference)) {
|
|
586
588
|
throw new Error(`toolbox.page 不允许远程或绝对资源: ${reference}`);
|
|
587
589
|
}
|
|
588
|
-
const
|
|
590
|
+
const sourcePath = resolve(dirname(fromFile), cleanReference);
|
|
591
|
+
const resolved = realpathSync(sourcePath);
|
|
589
592
|
const relativePath = relative(root, resolved);
|
|
590
593
|
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
591
594
|
throw new Error(`toolbox.page 资源超出插件目录: ${reference}`);
|
|
592
595
|
}
|
|
596
|
+
sources[sourcePath] = pluginFileVersion(sourcePath);
|
|
593
597
|
return resolved;
|
|
594
598
|
};
|
|
595
599
|
const readAsset = (path) => {
|
|
600
|
+
sources[path] ??= pluginFileVersion(path);
|
|
596
601
|
const content = readFileSync(path);
|
|
597
602
|
if (!countedFiles.has(path)) {
|
|
598
603
|
countedFiles.add(path);
|
|
@@ -676,5 +681,6 @@ export function buildPluginPageBundle(entry, pageEntry) {
|
|
|
676
681
|
hash: createHash('sha256').update(buffer).digest('hex'),
|
|
677
682
|
size: buffer.byteLength,
|
|
678
683
|
chunks: Math.ceil(buffer.byteLength / PLUGIN_PAGE_CHUNK_BYTES),
|
|
684
|
+
sources,
|
|
679
685
|
};
|
|
680
686
|
}
|
package/dist/pluginRegistry.d.ts
CHANGED
|
@@ -37,7 +37,12 @@ export interface PluginRegistration {
|
|
|
37
37
|
managedBy?: string;
|
|
38
38
|
toolbox?: PluginToolboxContribution;
|
|
39
39
|
pageBundle?: PluginPageBundleRegistration;
|
|
40
|
+
source?: {
|
|
41
|
+
entry: string;
|
|
42
|
+
page?: Record<string, string>;
|
|
43
|
+
};
|
|
40
44
|
}
|
|
45
|
+
export declare function pluginRegistrationNeedsRefresh(registration: PluginRegistration, includePage?: boolean): boolean;
|
|
41
46
|
export interface PluginRegistrationOptions {
|
|
42
47
|
managedBy?: string;
|
|
43
48
|
toolbox?: PluginToolboxContribution;
|
package/dist/pluginRegistry.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { COMMAND_MAP } from './commands.js';
|
|
6
6
|
import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
|
|
7
|
+
import { pluginFileVersion } from './pluginSource.js';
|
|
7
8
|
const RESERVED_NAMES = new Set([
|
|
8
9
|
'check',
|
|
9
10
|
'help',
|
|
@@ -19,6 +20,17 @@ export class PluginError extends Error {
|
|
|
19
20
|
this.code = code;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
export function pluginRegistrationNeedsRefresh(registration, includePage = false) {
|
|
24
|
+
if (!registration.source)
|
|
25
|
+
return true;
|
|
26
|
+
if (pluginFileVersion(registration.entry) !== registration.source.entry)
|
|
27
|
+
return true;
|
|
28
|
+
if (!includePage || !registration.toolbox)
|
|
29
|
+
return false;
|
|
30
|
+
if (!registration.source.page)
|
|
31
|
+
return true;
|
|
32
|
+
return Object.entries(registration.source.page).some(([path, version]) => pluginFileVersion(path) !== version);
|
|
33
|
+
}
|
|
22
34
|
export function pluginDirectory() {
|
|
23
35
|
return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
|
|
24
36
|
}
|
|
@@ -242,6 +254,7 @@ export function resolvePluginEntry(target) {
|
|
|
242
254
|
export function registerPlugin(name, target, description, options = {}) {
|
|
243
255
|
assertPluginName(name);
|
|
244
256
|
const entry = resolvePluginEntry(target);
|
|
257
|
+
const entryVersion = pluginFileVersion(entry);
|
|
245
258
|
const directory = pluginDirectory();
|
|
246
259
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
247
260
|
const file = join(directory, `${name}.json`);
|
|
@@ -268,6 +281,7 @@ export function registerPlugin(name, target, description, options = {}) {
|
|
|
268
281
|
}
|
|
269
282
|
const record = {
|
|
270
283
|
entry,
|
|
284
|
+
source: { entry: entryVersion, ...(builtPage ? { page: builtPage.sources } : {}) },
|
|
271
285
|
...(summary ? { description: summary } : {}),
|
|
272
286
|
...(managedBy ? { managedBy } : {}),
|
|
273
287
|
...(toolbox ? { toolbox } : {}),
|
|
@@ -324,12 +338,26 @@ export function findPluginRegistration(name) {
|
|
|
324
338
|
: undefined;
|
|
325
339
|
const toolbox = normalizePluginToolboxContribution(record.toolbox);
|
|
326
340
|
const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
|
|
341
|
+
const source = record.source;
|
|
342
|
+
if (source !== undefined &&
|
|
343
|
+
(!source ||
|
|
344
|
+
typeof source !== 'object' ||
|
|
345
|
+
Array.isArray(source) ||
|
|
346
|
+
typeof source.entry !== 'string' ||
|
|
347
|
+
(source.page !== undefined &&
|
|
348
|
+
(!source.page ||
|
|
349
|
+
typeof source.page !== 'object' ||
|
|
350
|
+
Array.isArray(source.page) ||
|
|
351
|
+
Object.entries(source.page).some(([path, version]) => !isAbsolute(path) || typeof version !== 'string'))))) {
|
|
352
|
+
throw new Error('invalid source');
|
|
353
|
+
}
|
|
327
354
|
return {
|
|
328
355
|
entry: record.entry,
|
|
329
356
|
...(description ? { description } : {}),
|
|
330
357
|
...(managedBy ? { managedBy } : {}),
|
|
331
358
|
...(toolbox ? { toolbox } : {}),
|
|
332
359
|
...(pageBundle ? { pageBundle } : {}),
|
|
360
|
+
...(source ? { source } : {}),
|
|
333
361
|
};
|
|
334
362
|
}
|
|
335
363
|
catch {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
/** 文件替换和同大小写入都需要使缓存失效;检测只读取文件元数据。 */
|
|
3
|
+
export function pluginFileVersion(path) {
|
|
4
|
+
const stat = statSync(path);
|
|
5
|
+
if (!stat.isFile())
|
|
6
|
+
throw new Error(`插件资源不是文件: ${path}`);
|
|
7
|
+
return `${stat.dev}-${stat.ino}-${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`;
|
|
8
|
+
}
|
package/dist/socket.js
CHANGED
|
@@ -96,12 +96,38 @@ function browserListFromProviderRecords(browsers) {
|
|
|
96
96
|
browsers: providerBrowsers,
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
+
function withDiscoveryWarning(result, error) {
|
|
100
|
+
const cause = error.cause ?? error;
|
|
101
|
+
const code = cause?.code;
|
|
102
|
+
const detail = code ?? (error instanceof Error ? error.message.split('\n')[0] : String(error));
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
warnings: [
|
|
106
|
+
...(Array.isArray(result.warnings) ? result.warnings : []),
|
|
107
|
+
`Local CDP discovery could not complete: ${detail}`,
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function cdpOnlyBrowserList(result) {
|
|
112
|
+
const browsers = Array.isArray(result.browsers) ? result.browsers : [];
|
|
113
|
+
const cdpBrowsers = browsers.filter(browser => browser.transport === 'cdp');
|
|
114
|
+
return {
|
|
115
|
+
...result,
|
|
116
|
+
count: cdpBrowsers.length,
|
|
117
|
+
connectedCount: cdpBrowsers.filter(browser => browser.status === 'connected').length,
|
|
118
|
+
browsers: cdpBrowsers,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
99
121
|
function areInstalledProvidersRunning() {
|
|
100
122
|
return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
|
|
101
123
|
}
|
|
102
124
|
function isConnectedBrowser(browser) {
|
|
103
125
|
return browser.status === undefined || browser.status === 'connected';
|
|
104
126
|
}
|
|
127
|
+
function isOrdinaryLocalBrowser(browser) {
|
|
128
|
+
return (!browser.browserId.startsWith('managed:') &&
|
|
129
|
+
(browser.transport === 'extension' || browser.transport === 'cdp'));
|
|
130
|
+
}
|
|
105
131
|
function supportsAction(browser, action, data) {
|
|
106
132
|
if (action === 'get_versions')
|
|
107
133
|
return true;
|
|
@@ -141,47 +167,62 @@ function selectBrowserResult(data) {
|
|
|
141
167
|
}
|
|
142
168
|
/** Deduplicates concurrent recovery attempts in long-lived clients such as the MCP server. */
|
|
143
169
|
let persistentSpawnPromise = null;
|
|
170
|
+
let discoveryListPromise = null;
|
|
171
|
+
let discoveryListCache = null;
|
|
172
|
+
const DISCOVERY_STARTUP_TIMEOUT_MS = 3_000;
|
|
173
|
+
const DISCOVERY_RETRY_INTERVAL_MS = 50;
|
|
174
|
+
const DISCOVERY_MISS_CACHE_MS = 2_000;
|
|
175
|
+
function instanceSocketPath(pid) {
|
|
176
|
+
return process.platform === 'win32'
|
|
177
|
+
? `\\\\.\\pipe\\mearl-agent-${pid}`
|
|
178
|
+
: path.join(os.tmpdir(), `mearl-agent-${pid}.sock`);
|
|
179
|
+
}
|
|
180
|
+
function discoveryCacheKey() {
|
|
181
|
+
const browsers = listLiveBrowsers()
|
|
182
|
+
.map(browser => [
|
|
183
|
+
browser.browserId,
|
|
184
|
+
browser.pid,
|
|
185
|
+
browser.socketPath,
|
|
186
|
+
browser.status,
|
|
187
|
+
browser.updatedAt,
|
|
188
|
+
])
|
|
189
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
190
|
+
const providers = listInstalledProviders()
|
|
191
|
+
.map(provider => provider.providerId)
|
|
192
|
+
.sort();
|
|
193
|
+
return JSON.stringify({ browsers, providers });
|
|
194
|
+
}
|
|
195
|
+
async function spawnNativeHost(args) {
|
|
196
|
+
const { spawn } = await import('node:child_process');
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const child = spawn('mearl-native-host', args, {
|
|
199
|
+
detached: true,
|
|
200
|
+
stdio: 'ignore',
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
});
|
|
203
|
+
child.once('error', reject);
|
|
204
|
+
child.once('spawn', () => {
|
|
205
|
+
child.unref();
|
|
206
|
+
resolve(child.pid);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
144
210
|
/**
|
|
145
211
|
* Attempt to spawn `mearl-native-host` in the background (detached).
|
|
146
|
-
* Browser discovery hosts probe the default local CDP endpoint for browser_list and only
|
|
147
|
-
* persist when a real browser is available.
|
|
148
212
|
* Returns true if a spawn was attempted (regardless of success).
|
|
149
213
|
*/
|
|
150
|
-
async function trySpawnNativeHost(
|
|
151
|
-
|
|
152
|
-
if (persistent && persistentSpawnPromise)
|
|
214
|
+
async function trySpawnNativeHost() {
|
|
215
|
+
if (persistentSpawnPromise)
|
|
153
216
|
return persistentSpawnPromise;
|
|
154
217
|
const spawnPromise = (async () => {
|
|
155
218
|
try {
|
|
156
|
-
|
|
157
|
-
return
|
|
158
|
-
const args = persistent ? ['--daemon'] : ['--daemon', '--discover-cdp'];
|
|
159
|
-
const child = spawn('mearl-native-host', args, {
|
|
160
|
-
detached: true,
|
|
161
|
-
stdio: 'ignore',
|
|
162
|
-
windowsHide: true,
|
|
163
|
-
});
|
|
164
|
-
let settled = false;
|
|
165
|
-
const finish = (ok) => {
|
|
166
|
-
if (settled)
|
|
167
|
-
return;
|
|
168
|
-
settled = true;
|
|
169
|
-
resolve(ok);
|
|
170
|
-
};
|
|
171
|
-
// `spawn ENOENT` 等错误会通过事件异步抛出;必须显式监听避免进程崩溃。
|
|
172
|
-
child.once('error', () => finish(false));
|
|
173
|
-
child.once('spawn', () => {
|
|
174
|
-
child.unref();
|
|
175
|
-
finish(true);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
219
|
+
await spawnNativeHost(['--daemon']);
|
|
220
|
+
return true;
|
|
178
221
|
}
|
|
179
222
|
catch {
|
|
180
223
|
return false;
|
|
181
224
|
}
|
|
182
225
|
})();
|
|
183
|
-
if (!persistent)
|
|
184
|
-
return spawnPromise;
|
|
185
226
|
persistentSpawnPromise = spawnPromise;
|
|
186
227
|
try {
|
|
187
228
|
return await spawnPromise;
|
|
@@ -191,6 +232,54 @@ async function trySpawnNativeHost(mode = 'control') {
|
|
|
191
232
|
persistentSpawnPromise = null;
|
|
192
233
|
}
|
|
193
234
|
}
|
|
235
|
+
async function discoverLocalBrowserList(timeoutSec, controlSource) {
|
|
236
|
+
if (discoveryListPromise)
|
|
237
|
+
return discoveryListPromise;
|
|
238
|
+
const cacheKey = discoveryCacheKey();
|
|
239
|
+
if (discoveryListCache &&
|
|
240
|
+
discoveryListCache.key === cacheKey &&
|
|
241
|
+
Date.now() < discoveryListCache.until) {
|
|
242
|
+
if (discoveryListCache.error)
|
|
243
|
+
throw discoveryListCache.error;
|
|
244
|
+
return discoveryListCache.result;
|
|
245
|
+
}
|
|
246
|
+
discoveryListCache = null;
|
|
247
|
+
const request = (async () => {
|
|
248
|
+
const pid = await spawnNativeHost(['--daemon', '--discover-cdp']);
|
|
249
|
+
if (pid === undefined)
|
|
250
|
+
throw new Error('Native host did not report its process id');
|
|
251
|
+
const socketPath = instanceSocketPath(pid);
|
|
252
|
+
const deadline = Date.now() + Math.min(timeoutSec * 1_000, DISCOVERY_STARTUP_TIMEOUT_MS);
|
|
253
|
+
while (true) {
|
|
254
|
+
try {
|
|
255
|
+
return await invokeOnce('browser_list', {}, timeoutSec, undefined, socketPath, controlSource);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (!isConnError(error) || Date.now() >= deadline)
|
|
259
|
+
throw error;
|
|
260
|
+
await new Promise(resolve => setTimeout(resolve, DISCOVERY_RETRY_INTERVAL_MS));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})();
|
|
264
|
+
discoveryListPromise = request;
|
|
265
|
+
try {
|
|
266
|
+
const result = await request;
|
|
267
|
+
if (!result.browsers?.some((browser) => browser.transport === 'cdp' &&
|
|
268
|
+
browser.status === 'connected' &&
|
|
269
|
+
!browser.browserId?.startsWith('managed:'))) {
|
|
270
|
+
discoveryListCache = { key: cacheKey, result, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
discoveryListCache = { key: cacheKey, error, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
if (discoveryListPromise === request)
|
|
280
|
+
discoveryListPromise = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
194
283
|
function readOwnedLegacySocketIdentity() {
|
|
195
284
|
if (process.platform === 'win32')
|
|
196
285
|
return null;
|
|
@@ -240,7 +329,7 @@ export function invoke(action, data = {}, options = {}) {
|
|
|
240
329
|
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
241
330
|
const resolvedData = resolveFileRefs(data);
|
|
242
331
|
if (action === 'browser_list') {
|
|
243
|
-
return dispatchInvoke(action, resolvedData, timeoutSec,
|
|
332
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
|
|
244
333
|
}
|
|
245
334
|
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
246
335
|
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
|
|
@@ -252,6 +341,11 @@ function isConnError(error) {
|
|
|
252
341
|
function isSocketSandboxError(error) {
|
|
253
342
|
return error?.code === 'EPERM';
|
|
254
343
|
}
|
|
344
|
+
function isUnavailableConnection(error) {
|
|
345
|
+
const cause = error.cause ?? error;
|
|
346
|
+
const code = cause?.code;
|
|
347
|
+
return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
|
|
348
|
+
}
|
|
255
349
|
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
|
|
256
350
|
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
|
|
257
351
|
if (!selector && configuredSocketPath) {
|
|
@@ -269,6 +363,44 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
269
363
|
}
|
|
270
364
|
const browsers = listLiveBrowsers();
|
|
271
365
|
const controllableBrowsers = browsers.filter(isConnectedBrowser);
|
|
366
|
+
if (action === 'browser_list' && transport === 'cdp') {
|
|
367
|
+
const registeredCdp = controllableBrowsers.find(browser => browser.transport === 'cdp' && isOrdinaryLocalBrowser(browser));
|
|
368
|
+
if (registeredCdp) {
|
|
369
|
+
try {
|
|
370
|
+
return await invokeOnce(action, data, timeoutSec, transport, registeredCdp.socketPath, controlSource);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (!isConnError(error))
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (isUnavailableConnection(error) &&
|
|
382
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
383
|
+
areInstalledProvidersRunning()) {
|
|
384
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// A Provider record can keep the registry nonempty without representing a local
|
|
390
|
+
// browser transport. Let the discovery daemon probe CDP before returning that inventory.
|
|
391
|
+
if (action === 'browser_list' && !controllableBrowsers.some(isOrdinaryLocalBrowser)) {
|
|
392
|
+
try {
|
|
393
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (isUnavailableConnection(error) &&
|
|
397
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
398
|
+
areInstalledProvidersRunning()) {
|
|
399
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
272
404
|
// Explicit target: must match a live browser; do not silently fall back.
|
|
273
405
|
if (selector) {
|
|
274
406
|
const match = findBrowser(browsers, selector);
|
|
@@ -301,7 +433,8 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
301
433
|
}
|
|
302
434
|
// No explicit target: prefer the most-recently-focused live browser; on a
|
|
303
435
|
// stale socket, fall through to the legacy path + auto-spawn.
|
|
304
|
-
const routableBrowsers = controllableBrowsers.filter(browser =>
|
|
436
|
+
const routableBrowsers = controllableBrowsers.filter(browser => (action !== 'browser_list' || browser.transport !== 'provider') &&
|
|
437
|
+
!(transport === 'cdp' && browser.transport === 'provider') &&
|
|
305
438
|
supportsAction(browser, action, data));
|
|
306
439
|
if (routableBrowsers.length > 0) {
|
|
307
440
|
const target = routableBrowsers[0];
|
|
@@ -318,10 +451,18 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
318
451
|
throw error;
|
|
319
452
|
}
|
|
320
453
|
}
|
|
321
|
-
if (action === 'browser_list'
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
454
|
+
if (action === 'browser_list') {
|
|
455
|
+
try {
|
|
456
|
+
return await discoverLocalBrowserList(timeoutSec, controlSource);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (isUnavailableConnection(error) &&
|
|
460
|
+
browsers.some(browser => browser.transport === 'provider') &&
|
|
461
|
+
areInstalledProvidersRunning()) {
|
|
462
|
+
return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
325
466
|
}
|
|
326
467
|
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
|
|
327
468
|
}
|
|
@@ -337,15 +478,9 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
|
|
|
337
478
|
if (shouldAttemptSpawn && !isRetry) {
|
|
338
479
|
if (err.code === 'ECONNREFUSED')
|
|
339
480
|
removeStaleLegacySocket(legacySocketIdentity);
|
|
340
|
-
const spawned = await trySpawnNativeHost(
|
|
481
|
+
const spawned = await trySpawnNativeHost();
|
|
341
482
|
if (spawned) {
|
|
342
|
-
// Wait for the native host to start listening. Discovery retries the legacy socket directly
|
|
343
|
-
// so the spawned host receives the probe even if another browser registers in the meantime.
|
|
344
483
|
await new Promise(r => setTimeout(r, 1200));
|
|
345
|
-
if (action === 'browser_list') {
|
|
346
|
-
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
|
|
347
|
-
/* isRetry */ true);
|
|
348
|
-
}
|
|
349
484
|
const browsers = listLiveBrowsers();
|
|
350
485
|
const target = browsers.find(browser => isConnectedBrowser(browser) &&
|
|
351
486
|
!(transport === 'cdp' && browser.transport === 'provider') &&
|
|
@@ -453,7 +588,8 @@ function formatConnectError(error, socketPath) {
|
|
|
453
588
|
if (error.code === 'ENOENT') {
|
|
454
589
|
return [
|
|
455
590
|
`未找到 Socket: ${socketPath}`,
|
|
456
|
-
'native host
|
|
591
|
+
'native host 自动拉起失败。先运行 mearl check --local;缺少命令时运行 npx @mearl/setup --local --yes。',
|
|
592
|
+
'自动拉起只使用默认 daemon 参数;自定义 CDP/Profile/浏览器参数需手工启动,参见 mearl-native-host --help。',
|
|
457
593
|
'',
|
|
458
594
|
'【方式一:浏览器插件模式】',
|
|
459
595
|
`1. 确认已安装 Mearl 浏览器插件(${EXTENSION_INSTALL_URL})`,
|
|
@@ -469,7 +605,7 @@ function formatConnectError(error, socketPath) {
|
|
|
469
605
|
if (error.code === 'ECONNREFUSED') {
|
|
470
606
|
return [
|
|
471
607
|
`Socket 连接被拒绝: ${socketPath}`,
|
|
472
|
-
'native host
|
|
608
|
+
'已尝试处理 stale socket,但 native host 自动恢复仍失败。请运行 mearl check --local 定位故障。',
|
|
473
609
|
'请确认 Chrome 浏览器正在运行,可尝试在 chrome://extensions 重新加载插件。',
|
|
474
610
|
'',
|
|
475
611
|
installHint,
|
package/dist/unified.js
CHANGED
|
@@ -449,7 +449,8 @@ export class UnifiedClient {
|
|
|
449
449
|
connect() {
|
|
450
450
|
return new Promise((resolve, reject) => {
|
|
451
451
|
if (!this.options.serverUrl) {
|
|
452
|
-
reject(new Error('Cloud server URL is required.
|
|
452
|
+
reject(new Error('Cloud server URL is required. Run mearl-cloud-server status on the Agent machine. ' +
|
|
453
|
+
'Resolution order: --server > MEARL_SERVER_URL > ~/.mearl/cloud-server.json.'));
|
|
453
454
|
return;
|
|
454
455
|
}
|
|
455
456
|
const ws = new WebSocket(this.options.serverUrl);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/client",
|
|
3
|
-
"version": "2.19.
|
|
3
|
+
"version": "2.19.3",
|
|
4
4
|
"description": "Unified Mearl SDK & CLI for local and remote browsers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"commander": "^15.0.0",
|
|
60
60
|
"fastest-levenshtein": "^1.0.16",
|
|
61
61
|
"ws": "^8.18.0",
|
|
62
|
-
"@mearl/cloud-types": "2.19.
|
|
63
|
-
"@mearl/provider": "2.19.
|
|
62
|
+
"@mearl/cloud-types": "2.19.3",
|
|
63
|
+
"@mearl/provider": "2.19.3"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^24.9.1",
|