@xcanwin/manyoyo 8.3.0 → 8.3.2

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.
@@ -1,128 +1,43 @@
1
1
  'use strict';
2
2
 
3
+ // Playwright 插件:容器内的浏览器怎么来,只有 4 种模式、同一时间只有一个。
4
+ // default 容器内 Xvfb 里的有头浏览器(无需宿主机进程)
5
+ // headed 宿主机有窗口的浏览器
6
+ // chrome 用户正在使用的 Chrome(经本机中继)
7
+ // vnc 独立容器里的浏览器,用 noVNC 网页观看
8
+ // 宿主机目录 current/ 以目录方式只读挂进容器的 /run/manyoyo-playwright/,里面的 config.json 一律原子替换,
9
+ // 因此切换模式后已运行的容器自动跟随,不需要重建。
10
+
3
11
  const fs = require('fs');
4
12
  const net = require('net');
5
13
  const os = require('os');
6
14
  const path = require('path');
7
15
  const crypto = require('crypto');
8
16
  const { spawn, spawnSync } = require('child_process');
9
- const { playwrightCliVersion: PLAYWRIGHT_CLI_VERSION } = require('../../package.json');
10
- const { resolveForcedRuntime } = require('../container-runtime');
11
-
12
- const EXTENSIONS = [
13
- ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
14
- ['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
15
- ['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
16
- ['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
17
- ['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
17
+ const { writeConfigFileSecure } = require('../secure-file');
18
+ const fingerprint = require('./fingerprint');
19
+ const extensions = require('./playwright-extensions');
20
+ const { findDevToolsActivePort } = require('./playwright-relay');
21
+
22
+ const MODES = ['headed', 'chrome', 'vnc'];
23
+ const MODE_HELP = [
24
+ ['(默认)', '容器内虚拟屏里的有头浏览器,无需任何命令'],
25
+ ['headed', '宿主机上有窗口的浏览器,亲眼看着 Agent 操作'],
26
+ ['chrome', '你正在用的 Chrome,沿用登录状态(有风险)'],
27
+ ['vnc', '独立容器里的浏览器,通过 noVNC 网页观看(无桌面的机器)']
18
28
  ];
29
+ const VNC_CONTAINER = 'my-playwright-vnc';
30
+ const VNC_ASSET_DIR = path.join(__dirname, 'playwright-assets');
31
+ const VNC_MOUNT_DIR = '/run/manyoyo-playwright-vnc';
32
+ const DEFAULT_PORTS = { port: 8935, vncPort: 5900, novncPort: 6080 };
33
+ const PROXY_BYPASS_HOSTS = ['host.docker.internal', 'host.containers.internal'];
34
+ const SERVER_START_TIMEOUT_MS = 30000;
35
+ const PROBE_TIMEOUT_MS = 5000;
36
+ // chrome 首次连接要等用户在 Chrome 弹出的“要允许远程调试吗?”里点允许
37
+ const CHROME_APPROVAL_TIMEOUT_MS = 60000;
19
38
 
20
- const SCENE_ORDER = ['mcp-cont-headless', 'mcp-cont-headed', 'mcp-host-headless', 'mcp-host-headed', 'cli-host-headless', 'cli-host-headed', 'dev-host-headed'];
21
-
22
- const SCENE_DEFS = {
23
- 'mcp-cont-headless': {
24
- type: 'container',
25
- engine: 'mcp',
26
- configFile: 'mcp-cont-headless.json',
27
- composeFile: 'compose-headless.yaml',
28
- projectName: 'my-playwright-mcp-cont-headless',
29
- containerName: 'my-playwright-mcp-cont-headless',
30
- portKey: 'mcpContHeadless',
31
- headless: true,
32
- listenHost: '0.0.0.0'
33
- },
34
- 'mcp-cont-headed': {
35
- type: 'container',
36
- engine: 'mcp',
37
- configFile: 'mcp-cont-headed.json',
38
- composeFile: 'compose-headed.yaml',
39
- projectName: 'my-playwright-mcp-cont-headed',
40
- containerName: 'my-playwright-mcp-cont-headed',
41
- portKey: 'mcpContHeaded',
42
- headless: false,
43
- listenHost: '0.0.0.0'
44
- },
45
- 'mcp-host-headless': {
46
- type: 'host',
47
- engine: 'mcp',
48
- configFile: 'mcp-host-headless.json',
49
- portKey: 'mcpHostHeadless',
50
- headless: true,
51
- listenHost: '127.0.0.1'
52
- },
53
- 'mcp-host-headed': {
54
- type: 'host',
55
- engine: 'mcp',
56
- configFile: 'mcp-host-headed.json',
57
- portKey: 'mcpHostHeaded',
58
- headless: false,
59
- listenHost: '127.0.0.1'
60
- },
61
- 'cli-host-headless': {
62
- type: 'host',
63
- engine: 'cli',
64
- configFile: 'cli-host-headless.json',
65
- portKey: 'cliHostHeadless',
66
- headless: true,
67
- listenHost: '0.0.0.0'
68
- },
69
- 'cli-host-headed': {
70
- type: 'host',
71
- engine: 'cli',
72
- configFile: 'cli-host-headed.json',
73
- portKey: 'cliHostHeaded',
74
- headless: false,
75
- listenHost: '0.0.0.0'
76
- },
77
- 'dev-host-headed': {
78
- type: 'host',
79
- engine: 'dev',
80
- configFile: 'dev-host-headed.json',
81
- headless: false
82
- }
83
- };
84
-
85
- const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
86
- const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
87
- const CONTAINER_EXTENSION_ROOT = '/app/extensions';
88
- const DEFAULT_FINGERPRINT_PROFILE = {
89
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
90
- locale: 'zh-CN',
91
- acceptLanguage: 'zh-CN,zh;q=0.9',
92
- timezoneId: 'Asia/Shanghai',
93
- width: 1366,
94
- height: 768
95
- };
96
- const DISABLE_WEBRTC_LAUNCH_ARGS = ['--disable-webrtc'];
97
-
98
- function isMcpScene(sceneName) {
99
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'mcp');
100
- }
101
-
102
- function isCliScene(sceneName) {
103
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'cli');
104
- }
105
-
106
- function isDevScene(sceneName) {
107
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'dev');
108
- }
109
-
110
- function isCliSessionScene(sceneName) {
111
- return isCliScene(sceneName) || isDevScene(sceneName);
112
- }
113
-
114
- function platformFromUserAgent(userAgent) {
115
- const ua = String(userAgent || '').toLowerCase();
116
- if (ua.includes('macintosh') || ua.includes('mac os x')) {
117
- return 'MacIntel';
118
- }
119
- if (ua.includes('windows')) {
120
- return 'Win32';
121
- }
122
- if (ua.includes('android') || ua.includes('linux')) {
123
- return 'Linux x86_64';
124
- }
125
- return 'MacIntel';
39
+ function asObject(value) {
40
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
126
41
  }
127
42
 
128
43
  function sleep(ms) {
@@ -130,1913 +45,877 @@ function sleep(ms) {
130
45
  }
131
46
 
132
47
  function tailText(filePath, lineCount) {
133
- if (!fs.existsSync(filePath)) {
48
+ try {
49
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).slice(-lineCount).join('\n');
50
+ } catch {
134
51
  return '';
135
52
  }
136
- const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
137
- return lines.slice(-lineCount).join('\n');
138
53
  }
139
54
 
140
- function asObject(value) {
141
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
142
- return {};
55
+ function randomAlnum(length) {
56
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
57
+ let out = '';
58
+ for (let i = 0; i < length; i += 1) {
59
+ out += chars[crypto.randomInt(0, chars.length)];
143
60
  }
144
- return value;
61
+ return out;
145
62
  }
146
63
 
147
- function asStringArray(value, fallback) {
148
- if (!Array.isArray(value)) {
149
- return fallback;
150
- }
151
- return value
152
- .map(item => String(item || '').trim())
153
- .filter(Boolean);
64
+ function shellValue(value) {
65
+ const text = String(value);
66
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
154
67
  }
155
68
 
156
- function asBoolean(value, fallback = false) {
157
- if (typeof value === 'boolean') {
158
- return value;
159
- }
160
- if (typeof value === 'string') {
161
- const normalized = value.trim().toLowerCase();
162
- if (normalized === 'true') {
163
- return true;
164
- }
165
- if (normalized === 'false') {
166
- return false;
167
- }
168
- }
169
- return fallback;
69
+ function portReady(port, host = '127.0.0.1', timeout = 300) {
70
+ return new Promise(resolve => {
71
+ const socket = net.createConnection({ host, port });
72
+ const finish = value => {
73
+ socket.destroy();
74
+ resolve(value);
75
+ };
76
+ socket.setTimeout(timeout);
77
+ socket.once('connect', () => finish(true));
78
+ socket.once('timeout', () => finish(false));
79
+ socket.once('error', () => finish(false));
80
+ });
170
81
  }
171
82
 
172
- function uniqueStrings(values) {
173
- const out = [];
174
- const seen = new Set();
175
- for (const value of values) {
176
- const text = String(value || '').trim();
177
- if (!text || seen.has(text)) {
178
- continue;
179
- }
180
- seen.add(text);
181
- out.push(text);
83
+ function pidAlive(pid) {
84
+ if (!Number.isInteger(pid) || pid <= 0) {
85
+ return false;
182
86
  }
183
- return out;
184
- }
185
-
186
- function isHostPermission(value) {
187
- if (value === '<all_urls>') {
87
+ try {
88
+ process.kill(pid, 0);
188
89
  return true;
90
+ } catch (error) {
91
+ return error.code === 'EPERM';
189
92
  }
190
- return /^(?:\*|http|https|file|ftp):\/\//.test(value);
191
- }
192
-
193
- function scriptSourcesFromHtml(htmlFile) {
194
- const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
195
- const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
196
- return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
197
93
  }
198
94
 
199
- function convertManifestV2ToV3(extDir) {
200
- const manifestFile = path.join(extDir, 'manifest.json');
201
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
202
- if (manifest.manifest_version !== 2) {
95
+ // pid 可能在重启后被不相干的进程复用:确认它的命令行还是我们起的服务脚本
96
+ function pidIsOurs(pid) {
97
+ if (!pidAlive(pid)) {
203
98
  return false;
204
99
  }
100
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
101
+ return /playwright-(server|relay)\.js/.test(result.stdout || '');
102
+ }
205
103
 
206
- manifest.manifest_version = 3;
207
-
208
- if (manifest.browser_action && !manifest.action) {
209
- manifest.action = manifest.browser_action;
210
- delete manifest.browser_action;
211
- }
212
- if (manifest.page_action && !manifest.action) {
213
- manifest.action = manifest.page_action;
214
- delete manifest.page_action;
215
- }
216
-
217
- const background = manifest.background;
218
- if (background && typeof background === 'object' && !Array.isArray(background)) {
219
- let scripts = [];
220
- if (Array.isArray(background.scripts)) {
221
- scripts = background.scripts.filter(s => typeof s === 'string');
222
- } else if (typeof background.page === 'string') {
223
- const pagePath = path.join(extDir, background.page);
224
- if (fs.existsSync(pagePath)) {
225
- scripts = scriptSourcesFromHtml(pagePath);
226
- }
227
- }
228
-
229
- if (scripts.length > 0) {
230
- const swName = 'generated_background_sw.js';
231
- const swFile = path.join(extDir, swName);
232
- const swLines = [
233
- '// Auto-generated by manyoyo playwright ext-download for MV3.',
234
- `importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
235
- ''
236
- ];
237
- fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
238
- manifest.background = { service_worker: swName };
239
- } else {
240
- delete manifest.background;
241
- }
242
- }
243
-
244
- if (typeof manifest.content_security_policy === 'string') {
245
- manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
246
- }
247
-
248
- if (Array.isArray(manifest.permissions)) {
249
- const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
250
- const keptPermissions = [];
251
-
252
- for (const perm of manifest.permissions) {
253
- if (typeof perm === 'string' && isHostPermission(perm)) {
254
- if (!hostPermissions.includes(perm)) {
255
- hostPermissions.push(perm);
256
- }
257
- } else {
258
- keptPermissions.push(perm);
259
- }
260
- }
261
-
262
- manifest.permissions = keptPermissions;
263
- if (hostPermissions.length > 0) {
264
- manifest.host_permissions = hostPermissions;
265
- }
266
- }
104
+ // 宿主机监听地址:macOS 的容器能访问宿主机 loopback;Linux 容器只能访问 0.0.0.0
105
+ function defaultListenHost(platform = process.platform) {
106
+ return platform === 'darwin' ? '127.0.0.1' : '0.0.0.0';
107
+ }
267
108
 
268
- const war = manifest.web_accessible_resources;
269
- if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
270
- manifest.web_accessible_resources = [
271
- {
272
- resources: war,
273
- matches: ['<all_urls>']
274
- }
275
- ];
276
- }
109
+ // headed 的浏览器服务必须监听 0.0.0.0:playwright 只绑 loopback 时会按 Host 头做 DNS 重绑定防护,
110
+ // 容器用 host.containers.internal 访问会被 403(macOS 上实测);监听 0.0.0.0 则不检查 Host,靠 token 保护
111
+ function headedListenHost() {
112
+ return '0.0.0.0';
113
+ }
277
114
 
278
- fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
279
- return true;
115
+ function runtimeKind(runtimeCommand) {
116
+ return path.basename(String(runtimeCommand || '').trim()).toLowerCase() === 'podman' ? 'podman' : 'docker';
280
117
  }
281
118
 
282
- function buildCrxUrl(extId, prodversion) {
283
- return (
284
- 'https://clients2.google.com/service/update2/crx' +
285
- `?response=redirect&prodversion=${prodversion}` +
286
- '&acceptformat=crx2,crx3' +
287
- `&x=id%3D${extId}%26installsource%3Dondemand%26uc`
288
- );
119
+ function connectHostFor(runtimeCommand) {
120
+ return runtimeKind(runtimeCommand) === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
289
121
  }
290
122
 
291
- function crxZipOffset(data) {
292
- if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
293
- throw new Error('not a CRX file');
123
+ function appendNoProxy(existing) {
124
+ const parts = String(existing || '').split(',').map(item => item.trim()).filter(Boolean);
125
+ for (const host of PROXY_BYPASS_HOSTS) {
126
+ if (!parts.includes(host)) {
127
+ parts.push(host);
128
+ }
294
129
  }
130
+ return parts.join(',');
131
+ }
295
132
 
296
- const version = data.readUInt32LE(4);
297
- if (version === 2) {
298
- const pubLen = data.readUInt32LE(8);
299
- const sigLen = data.readUInt32LE(12);
300
- return 16 + pubLen + sigLen;
301
- }
302
- if (version === 3) {
303
- const headerLen = data.readUInt32LE(8);
304
- return 12 + headerLen;
133
+ function envEntryValue(entries, key) {
134
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
135
+ const text = String(entries[i]);
136
+ if (text.startsWith(`${key}=`)) {
137
+ return text.slice(key.length + 1);
138
+ }
305
139
  }
306
- throw new Error(`unsupported CRX version: ${version}`);
140
+ return undefined;
307
141
  }
308
142
 
309
143
  class PlaywrightPlugin {
310
144
  constructor(options = {}) {
311
- this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
312
145
  this.stdout = options.stdout || process.stdout;
313
146
  this.stderr = options.stderr || process.stderr;
314
- this.globalConfig = asObject(options.globalConfig);
315
- this.runConfig = asObject(options.runConfig);
316
147
  this.rootGlobalConfig = asObject(options.rootGlobalConfig);
317
148
  this.rootRunConfig = asObject(options.rootRunConfig);
318
- this.runtimeEnv = {};
319
- this.config = this.resolveConfig();
320
- }
321
-
322
- resolveConfig() {
323
- const homeDir = os.homedir();
324
- const pluginRootDir = path.join(homeDir, '.manyoyo', 'plugin', 'playwright');
325
- const defaultConfig = {
326
- homeDir,
327
- runtime: 'mixed',
328
- enabledScenes: [...SCENE_ORDER],
329
- cliSessionScene: 'cli-host-headless',
330
- mcpDefaultHost: 'host.docker.internal',
331
- dockerTag: process.env.PLAYWRIGHT_MCP_DOCKER_TAG || 'latest',
332
- containerRuntime: '',
333
- vncPasswordEnvKey: 'VNC_PASSWORD',
334
- headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
335
- configDir: path.join(pluginRootDir, 'config'),
336
- runDir: path.join(pluginRootDir, 'run'),
337
- extensionProdversion: '132.0.0.0',
338
- navigatorPlatform: platformFromUserAgent(DEFAULT_FINGERPRINT_PROFILE.userAgent),
339
- disableWebRTC: false,
340
- devtoolsActivePortPath: '',
341
- devtoolsCdpTimeout: 60000,
342
- composeDir: path.join(__dirname, 'playwright-assets'),
343
- ports: {
344
- mcpContHeadless: 8931,
345
- mcpContHeaded: 8932,
346
- mcpHostHeadless: 8933,
347
- mcpHostHeaded: 8934,
348
- cliHostHeadless: 8935,
349
- cliHostHeaded: 8936,
350
- mcpContHeadedNoVnc: 6080
351
- }
149
+ this.runtime = options.runtime || null;
150
+ this.homeDir = options.homeDir || os.homedir();
151
+ this.root = path.join(this.homeDir, '.manyoyo', 'plugin', 'playwright');
152
+ this.currentDir = path.join(this.root, 'current');
153
+ this.runDir = path.join(this.root, 'run');
154
+ this.config = this.resolveConfig(asObject(options.globalConfig), asObject(options.runConfig));
155
+ }
156
+
157
+ resolveConfig(globalConfig, runConfig) {
158
+ const merged = { ...globalConfig, ...runConfig };
159
+ const host = fingerprint.detectHostProfile();
160
+ const port = name => {
161
+ const value = Number(merged[name] || DEFAULT_PORTS[name]);
162
+ if (!Number.isInteger(value) || value <= 0 || value > 65535) {
163
+ throw new Error(`plugins.playwright.${name} 无效: ${merged[name]}`);
164
+ }
165
+ return value;
352
166
  };
353
-
354
- const merged = {
355
- ...defaultConfig,
356
- ...this.globalConfig,
357
- ...this.runConfig,
358
- ports: {
359
- ...defaultConfig.ports,
360
- ...asObject(this.globalConfig.ports),
361
- ...asObject(this.runConfig.ports)
362
- }
167
+ return {
168
+ port: port('port'),
169
+ vncPort: port('vncPort'),
170
+ novncPort: port('novncPort'),
171
+ locale: String(merged.locale || '').trim() || host.locale,
172
+ timezoneId: String(merged.timezoneId || '').trim() || host.timezoneId,
173
+ navigatorPlatform: String(merged.navigatorPlatform || '').trim(),
174
+ disableWebRTC: merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true',
175
+ devtoolsActivePortPath: String(merged.devtoolsActivePortPath || '').trim(),
176
+ extensionProdversion: String(merged.extensionProdversion || '').trim() || '132.0.0.0'
363
177
  };
364
-
365
- merged.runtime = String(merged.runtime || defaultConfig.runtime).trim().toLowerCase();
366
- if (!VALID_RUNTIME.has(merged.runtime)) {
367
- throw new Error(`playwright.runtime 无效: ${merged.runtime}`);
368
- }
369
-
370
- merged.enabledScenes = asStringArray(
371
- this.runConfig.enabledScenes,
372
- asStringArray(this.globalConfig.enabledScenes, [...defaultConfig.enabledScenes])
373
- );
374
- merged.containerRuntime = this.resolveContainerRuntime(merged.containerRuntime);
375
- merged.cliSessionScene = String(merged.cliSessionScene || defaultConfig.cliSessionScene).trim();
376
- merged.navigatorPlatform = String(merged.navigatorPlatform || defaultConfig.navigatorPlatform).trim() || defaultConfig.navigatorPlatform;
377
- merged.disableWebRTC = asBoolean(merged.disableWebRTC, defaultConfig.disableWebRTC);
378
-
379
- if (merged.enabledScenes.length === 0) {
380
- throw new Error('playwright.enabledScenes 不能为空');
381
- }
382
-
383
- const invalidScene = merged.enabledScenes.find(scene => !SCENE_DEFS[scene]);
384
- if (invalidScene) {
385
- throw new Error(`playwright.enabledScenes 包含未知场景: ${invalidScene}`);
386
- }
387
- merged.devtoolsActivePortPath = String(merged.devtoolsActivePortPath || '').trim();
388
- merged.devtoolsCdpTimeout = Number(merged.devtoolsCdpTimeout) || defaultConfig.devtoolsCdpTimeout;
389
-
390
- if (merged.cliSessionScene && !isCliSessionScene(merged.cliSessionScene)) {
391
- throw new Error(`playwright.cliSessionScene 无效: ${merged.cliSessionScene}`);
392
- }
393
-
394
- return merged;
395
178
  }
396
179
 
397
- resolveContainerRuntime(configuredRuntime) {
398
- const configured = String(configuredRuntime || '').trim().toLowerCase();
399
- if (configured) {
400
- return configured;
401
- }
402
-
403
- const forced = resolveForcedRuntime({ configured: this.rootGlobalConfig.containerRuntime });
404
- if (forced) {
405
- this.runtimeEnv = forced.env;
406
- return forced.command;
407
- }
408
-
409
- const candidates = ['docker', 'podman'];
410
- for (const cmd of candidates) {
411
- if (this.ensureCommandAvailable(cmd)) {
412
- return cmd;
413
- }
414
- }
415
-
416
- return 'docker';
180
+ profile() {
181
+ const { locale, timezoneId, navigatorPlatform, disableWebRTC } = this.config;
182
+ return { locale, timezoneId, navigatorPlatform, disableWebRTC };
417
183
  }
418
184
 
419
- writeStdout(line = '') {
185
+ out(line = '') {
420
186
  this.stdout.write(`${line}\n`);
421
187
  }
422
188
 
423
- writeStderr(line = '') {
189
+ err(line = '') {
424
190
  this.stderr.write(`${line}\n`);
425
191
  }
426
192
 
427
- remindCliSessionScene(sceneName) {
428
- if (!isCliScene(sceneName)) {
429
- return;
430
- }
431
- if (sceneName !== 'cli-host-headed') {
432
- return;
433
- }
434
- if (this.cliHostContainerAttachConfigReady()) {
435
- return;
193
+ // ---------- 路径与状态 ----------
194
+
195
+ statePath() { return path.join(this.runDir, 'state.json'); }
196
+ tokenPath() { return path.join(this.runDir, 'token'); }
197
+ logPath() { return path.join(this.runDir, 'server.log'); }
198
+ hostConfigPath() { return path.join(this.root, 'host.json'); }
199
+
200
+ ensureDirs() {
201
+ for (const dir of [this.root, this.currentDir, this.runDir]) {
202
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
436
203
  }
437
- this.writeStdout('[tip] 如果希望容器内 manyoyo run 自动附着到当前 CLI 宿主场景,请在 ~/.manyoyo/manyoyo.json 中设置:');
438
- this.writeStdout('{');
439
- this.writeStdout(' "volumes": [');
440
- this.writeStdout(' "~/.manyoyo/.cache/ms-playwright:/root/.cache/ms-playwright"');
441
- this.writeStdout(' ],');
442
- this.writeStdout(' "plugins": {');
443
- this.writeStdout(' "playwright": {');
444
- this.writeStdout(' "cliSessionScene": "cli-host-headed"');
445
- this.writeStdout(' }');
446
- this.writeStdout(' }');
447
- this.writeStdout('}');
448
204
  }
449
205
 
450
- remoteDebuggingPageUrl() {
451
- return 'chrome://inspect/#remote-debugging';
206
+ readState() {
207
+ try {
208
+ const state = JSON.parse(fs.readFileSync(this.statePath(), 'utf8'));
209
+ return state && MODES.includes(state.mode) ? state : { mode: 'default' };
210
+ } catch {
211
+ return { mode: 'default' };
212
+ }
452
213
  }
453
214
 
454
- openChromeRemoteDebuggingPage() {
455
- const url = this.remoteDebuggingPageUrl();
456
- const platform = process.platform;
457
- let commands = [];
458
-
459
- if (platform === 'darwin') {
460
- commands = [
461
- ['open', '-a', 'Google Chrome', url],
462
- ['open', url]
463
- ];
464
- } else if (platform === 'win32') {
465
- commands = [
466
- ['cmd', '/c', 'start', '', 'chrome', url]
467
- ];
468
- } else {
469
- commands = [
470
- ['google-chrome', url],
471
- ['google-chrome-stable', url],
472
- ['chromium', url],
473
- ['chromium-browser', url]
474
- ];
475
- }
215
+ writeState(state) {
216
+ this.ensureDirs();
217
+ writeConfigFileSecure(this.statePath(), `${JSON.stringify(state, null, 4)}\n`);
218
+ }
476
219
 
477
- for (const args of commands) {
478
- const result = this.runCmd(args, { check: false, captureOutput: true });
479
- if (result.returncode === 0) {
480
- return true;
220
+ // token 首次生成后持久化,重启服务不换;只有 down 才会让它失效
221
+ ensureToken() {
222
+ this.ensureDirs();
223
+ try {
224
+ const token = fs.readFileSync(this.tokenPath(), 'utf8').trim();
225
+ if (/^[0-9a-f]{64}$/.test(token)) {
226
+ return token;
481
227
  }
228
+ } catch {
229
+ // 还没有,下面生成
482
230
  }
483
- return false;
231
+ const token = crypto.randomBytes(32).toString('hex');
232
+ writeConfigFileSecure(this.tokenPath(), `${token}\n`);
233
+ return token;
484
234
  }
485
235
 
486
- writeDevHostUsage(configPath) {
487
- this.writeStdout('');
488
- this.writeStdout('若用容器 Agent 操作宿主机浏览器,在宿主机执行:');
489
- ['claude', 'codex', 'gemini'].forEach(runName => {
490
- this.writeStdout(`my run -r ${runName}`);
491
- });
492
- this.writeStdout('');
493
- this.writeStdout('若用宿主机 Agent 操作宿主机浏览器,在宿主机执行:');
494
- ['claude', 'codex', 'gemini'].forEach(command => {
495
- this.writeStdout(this.devHostAttachCommand(configPath, command));
496
- });
236
+ readPersisted(name) {
237
+ try {
238
+ return fs.readFileSync(path.join(this.runDir, name), 'utf8').trim();
239
+ } catch {
240
+ return '';
241
+ }
497
242
  }
498
243
 
499
- randomAlnum(length = 16) {
500
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
501
- let out = '';
502
- for (let i = 0; i < length; i += 1) {
503
- out += chars[crypto.randomInt(0, chars.length)];
244
+ ensureVncPassword() {
245
+ const existing = this.readPersisted('vnc-password');
246
+ if (existing) {
247
+ return existing;
504
248
  }
505
- return out;
249
+ const password = randomAlnum(8);
250
+ writeConfigFileSecure(path.join(this.runDir, 'vnc-password'), `${password}\n`);
251
+ return password;
506
252
  }
507
253
 
508
- runCmd(args, { env = null, captureOutput = false, check = true } = {}) {
509
- const isRuntimeCommand = args[0] === this.config.containerRuntime;
510
- const result = spawnSync(args[0], args.slice(1), {
511
- encoding: 'utf8',
512
- env: isRuntimeCommand ? { ...(env || process.env), ...this.runtimeEnv } : (env || process.env),
513
- stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
514
- });
515
-
516
- const completed = {
517
- returncode: result.status === null ? 1 : result.status,
518
- stdout: typeof result.stdout === 'string' ? result.stdout : '',
519
- stderr: typeof result.stderr === 'string' ? result.stderr : ''
520
- };
254
+ // ---------- 容器侧配置(挂载目录里的 config.json) ----------
521
255
 
522
- if (result.error) {
523
- if (check) {
524
- throw result.error;
525
- }
526
- completed.returncode = 1;
527
- return completed;
256
+ containerConfigFor(state, runtimeCommand) {
257
+ const profile = this.profile();
258
+ const host = connectHostFor(runtimeCommand);
259
+ if (state.mode === 'chrome') {
260
+ return fingerprint.buildContainerConfig('cdp', {
261
+ ...profile,
262
+ endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
263
+ });
528
264
  }
529
-
530
- if (check && completed.returncode !== 0) {
531
- const error = new Error(`command failed with exit code ${completed.returncode}`);
532
- error.returncode = completed.returncode;
533
- error.stdout = completed.stdout;
534
- error.stderr = completed.stderr;
535
- throw error;
265
+ if (state.mode === 'headed' || state.mode === 'vnc') {
266
+ return fingerprint.buildContainerConfig('ws', {
267
+ ...profile,
268
+ endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
269
+ });
536
270
  }
537
-
538
- return completed;
271
+ return fingerprint.buildContainerConfig('default', profile);
539
272
  }
540
273
 
541
- ensureCommandAvailable(command) {
542
- const name = String(command || '').trim();
543
- if (path.isAbsolute(name)) {
544
- return fs.existsSync(name);
274
+ // 原子替换:目录挂载能看到 rename 之后的新文件
275
+ writeCurrentConfig(state, runtimeCommand) {
276
+ this.ensureDirs();
277
+ const config = `${JSON.stringify(this.containerConfigFor(state, runtimeCommand), null, 4)}\n`;
278
+ const initScript = fingerprint.buildInitScript(this.profile());
279
+ const configPath = path.join(this.currentDir, 'config.json');
280
+ const initPath = path.join(this.currentDir, 'stealth.init.js');
281
+ const same = (file, content) => {
282
+ try {
283
+ return fs.readFileSync(file, 'utf8') === content;
284
+ } catch {
285
+ return false;
286
+ }
287
+ };
288
+ if (!same(initPath, initScript)) {
289
+ writeConfigFileSecure(initPath, initScript);
545
290
  }
546
- if (!/^[A-Za-z0-9._-]+$/.test(name)) {
547
- return false;
291
+ if (!same(configPath, config)) {
292
+ writeConfigFileSecure(configPath, config);
548
293
  }
549
- const result = spawnSync('sh', ['-c', `command -v ${name}`], {
550
- encoding: 'utf8',
551
- stdio: ['ignore', 'ignore', 'ignore']
552
- });
553
- return result.status === 0;
554
- }
555
-
556
- scenePort(sceneName) {
557
- const def = SCENE_DEFS[sceneName];
558
- return Number(this.config.ports[def.portKey]);
294
+ return configPath;
559
295
  }
560
296
 
561
- sceneConfigPath(sceneName) {
562
- const def = SCENE_DEFS[sceneName];
563
- return path.join(this.config.configDir, def.configFile);
297
+ // 宿主机上的 Agent 用的配置(路径与端点都是宿主机视角)
298
+ writeHostConfig(state) {
299
+ const profile = this.profile();
300
+ const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
301
+ const initPath = path.join(this.root, 'host.init.js');
302
+ writeConfigFileSecure(initPath, fingerprint.buildInitScript(profile));
303
+ const config = state.mode === 'chrome'
304
+ ? fingerprint.buildContainerConfig('cdp', { endpoint })
305
+ : fingerprint.buildContainerConfig('ws', { ...profile, endpoint });
306
+ config.outputDir = path.join(os.tmpdir(), '.playwright-cli');
307
+ if (config.browser.initScript) {
308
+ config.browser.initScript = [initPath];
309
+ }
310
+ writeConfigFileSecure(this.hostConfigPath(), `${JSON.stringify(config, null, 4)}\n`);
311
+ return this.hostConfigPath();
564
312
  }
565
313
 
566
- sceneConfigMissing(sceneName) {
567
- return !fs.existsSync(this.sceneConfigPath(sceneName));
314
+ userDisplayPath(filePath) {
315
+ return filePath.startsWith(`${this.homeDir}${path.sep}`) ? `~${filePath.slice(this.homeDir.length)}` : filePath;
568
316
  }
569
317
 
570
- sceneEndpointPath(sceneName) {
571
- return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
572
- }
318
+ // ---------- 容器集成(CLI 与 Web 共用) ----------
573
319
 
574
- readSceneEndpoint(sceneName) {
575
- const filePath = this.sceneEndpointPath(sceneName);
576
- if (!fs.existsSync(filePath)) {
577
- return null;
320
+ // 模式是否真的还活着(只看进程与端口,足够快;真实探测见 probe)
321
+ async modeAlive(state) {
322
+ if (state.mode === 'default') {
323
+ return true;
578
324
  }
579
- try {
580
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
581
- } catch {
582
- return null;
325
+ if (state.mode === 'vnc') {
326
+ // 发布端口由运行时转发,容器没了端口也可能还能连上,所以看容器状态
327
+ try {
328
+ const inspect = this.runtimeCall(['inspect', '--format', '{{.State.Running}}', VNC_CONTAINER]);
329
+ return inspect.status === 0 && inspect.stdout.trim() === 'true' && await portReady(state.port);
330
+ } catch {
331
+ return false;
332
+ }
583
333
  }
334
+ return pidIsOurs(state.pid) && await portReady(state.port);
584
335
  }
585
336
 
586
- writeSceneEndpoint(sceneName, payload) {
587
- fs.mkdirSync(this.config.runDir, { recursive: true });
588
- fs.writeFileSync(this.sceneEndpointPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
589
- }
590
-
591
- removeSceneEndpoint(sceneName) {
592
- fs.rmSync(this.sceneEndpointPath(sceneName), { force: true });
593
- }
594
-
595
- sceneCliAttachConfigPath(sceneName) {
596
- return path.join(this.config.runDir, `${sceneName}.cli-attach.json`);
337
+ // 返回 { env, volumes, extraArgs, warning };永远不抛错,不因 playwright 让 run 退出
338
+ async buildContainerIntegration({ runtimeCommand, envEntries = [], dryRun = false } = {}) {
339
+ let warning = '';
340
+ try {
341
+ // 预览命令(dryRun)不写任何文件、不改状态
342
+ if (!dryRun) {
343
+ this.ensureDirs();
344
+ }
345
+ let state = this.readState();
346
+ if (dryRun) {
347
+ state = { mode: 'default' };
348
+ } else if (!(await this.modeAlive(state))) {
349
+ warning = `Playwright ${state.mode} 模式已失效,容器内浏览器已回退到默认模式。恢复: manyoyo playwright up ${state.mode}`;
350
+ state = { mode: 'default' };
351
+ this.writeState(state);
352
+ }
353
+ if (!dryRun) {
354
+ this.writeCurrentConfig(state, runtimeCommand);
355
+ }
356
+ } catch (error) {
357
+ return { env: [], volumes: [], extraArgs: [], warning: `Playwright 集成不可用: ${error.message || String(error)}` };
358
+ }
359
+ const noProxy = appendNoProxy(envEntryValue(envEntries, 'NO_PROXY') ?? envEntryValue(envEntries, 'no_proxy')
360
+ ?? process.env.NO_PROXY ?? process.env.no_proxy);
361
+ return {
362
+ env: [
363
+ `PLAYWRIGHT_MCP_CONFIG=${fingerprint.CONTAINER_CONFIG_PATH}`,
364
+ 'NO_UPDATE_NOTIFIER=1',
365
+ `NO_PROXY=${noProxy}`,
366
+ `no_proxy=${noProxy}`
367
+ ],
368
+ volumes: [`${this.currentDir}:${fingerprint.CONTAINER_CONFIG_DIR}:ro`],
369
+ extraArgs: runtimeKind(runtimeCommand) === 'docker' ? ['--add-host', 'host.docker.internal:host-gateway'] : [],
370
+ warning
371
+ };
597
372
  }
598
373
 
599
- writeSceneCliAttachConfig(sceneName, payload) {
600
- fs.mkdirSync(this.config.runDir, { recursive: true });
601
- fs.writeFileSync(this.sceneCliAttachConfigPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
602
- }
374
+ // ---------- 运行时与命令 ----------
603
375
 
604
- removeSceneCliAttachConfig(sceneName) {
605
- fs.rmSync(this.sceneCliAttachConfigPath(sceneName), { force: true });
376
+ resolveRuntime() {
377
+ if (!this.runtime) {
378
+ const { selectContainerRuntime } = require('../container-runtime');
379
+ this.runtime = selectContainerRuntime({ configured: this.rootGlobalConfig.containerRuntime });
380
+ }
381
+ return this.runtime;
606
382
  }
607
383
 
608
- buildDevHostAttachConfig(endpoint, cdpHost) {
384
+ runtimeCall(args, { capture = true } = {}) {
385
+ const runtime = this.resolveRuntime();
386
+ const result = spawnSync(runtime.command, args, {
387
+ encoding: 'utf8',
388
+ env: { ...process.env, ...runtime.env },
389
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
390
+ });
609
391
  return {
610
- outputDir: '/tmp/.playwright-cli',
611
- browser: {
612
- cdpEndpoint: `ws://${cdpHost}:${endpoint.port}${endpoint.wsPath}`,
613
- cdpHeaders: {
614
- Host: `127.0.0.1:${endpoint.port}`
615
- },
616
- cdpTimeout: this.config.devtoolsCdpTimeout
617
- }
392
+ status: result.error ? 1 : (result.status === null ? 1 : result.status),
393
+ stdout: result.stdout || '',
394
+ stderr: result.stderr || (result.error ? String(result.error.message) : '')
618
395
  };
619
396
  }
620
397
 
621
- writeDevHostAttachConfig(sceneName, endpoint) {
622
- fs.mkdirSync(this.config.configDir, { recursive: true });
623
- const filePath = this.sceneConfigPath(sceneName);
624
- fs.writeFileSync(filePath, `${JSON.stringify(this.buildDevHostAttachConfig(endpoint, '127.0.0.1'), null, 4)}\n`, 'utf8');
625
- return filePath;
398
+ corePath() {
399
+ const cliPackage = require.resolve('@playwright/cli/package.json');
400
+ return path.dirname(require.resolve('playwright-core/package.json', { paths: [path.dirname(cliPackage)] }));
626
401
  }
627
402
 
628
- removeDevHostAttachConfig(sceneName) {
629
- fs.rmSync(this.sceneConfigPath(sceneName), { force: true });
403
+ coreCli() {
404
+ return path.join(this.corePath(), 'cli.js');
630
405
  }
631
406
 
632
- userDisplayPath(filePath) {
633
- const normalizedPath = path.resolve(filePath);
634
- const normalizedHome = path.resolve(this.config.homeDir);
635
- if (normalizedPath === normalizedHome) {
636
- return '~';
637
- }
638
- if (normalizedPath.startsWith(`${normalizedHome}${path.sep}`)) {
639
- return `~${normalizedPath.slice(normalizedHome.length)}`;
640
- }
641
- return filePath;
407
+ // 浏览器与探测都用 @playwright/cli 自带的 playwright-core,保证与容器内版本一致
408
+ installCommand(args) {
409
+ return `node ${shellValue(this.coreCli())} ${args}`;
642
410
  }
643
411
 
644
- shellValue(value) {
645
- const text = String(value);
646
- if (/^[A-Za-z0-9_@%+=:,./~-]+$/.test(text)) {
647
- return text;
412
+ ensureBrowserInstalled() {
413
+ const result = spawnSync(process.execPath, [this.coreCli(), 'install', '--no-shell', 'chromium'], { stdio: 'inherit' });
414
+ if (result.status !== 0) {
415
+ throw new Error(`浏览器安装失败,请手动执行: ${this.installCommand('install --no-shell chromium')}`);
648
416
  }
649
- return `'${text.replace(/'/g, "'\\''")}'`;
650
417
  }
651
418
 
652
- devHostAttachEnvEntries(configPath) {
653
- return [
654
- `PLAYWRIGHT_MCP_CONFIG=${this.shellValue(this.userDisplayPath(configPath))}`
655
- ];
656
- }
419
+ // ---------- 探测 ----------
657
420
 
658
- devHostAttachCommand(configPath, command) {
659
- return `${this.devHostAttachEnvEntries(configPath).join(' ')} ${command}`;
421
+ probe(kind, endpoint, timeout = PROBE_TIMEOUT_MS) {
422
+ return new Promise(resolve => {
423
+ const child = spawn(process.execPath, [path.join(__dirname, 'playwright-probe.js')], {
424
+ env: {
425
+ ...process.env,
426
+ MANYOYO_PROBE: JSON.stringify({ core: this.corePath(), kind, endpoint, timeout })
427
+ },
428
+ stdio: ['ignore', 'pipe', 'ignore']
429
+ });
430
+ let output = '';
431
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeout + 3000);
432
+ child.stdout.on('data', chunk => { output += chunk; });
433
+ child.on('error', () => {
434
+ clearTimeout(timer);
435
+ resolve({ ok: false, error: '无法启动探测进程' });
436
+ });
437
+ child.on('close', () => {
438
+ clearTimeout(timer);
439
+ try {
440
+ resolve(JSON.parse(output.trim().split('\n').pop()));
441
+ } catch {
442
+ resolve({ ok: false, error: '探测超时或无输出' });
443
+ }
444
+ });
445
+ });
660
446
  }
661
447
 
662
- expandHomePath(value) {
663
- const text = String(value || '').trim();
664
- if (text === '~') {
665
- return this.config.homeDir;
666
- }
667
- if (text.startsWith('~/')) {
668
- return path.join(this.config.homeDir, text.slice(2));
669
- }
670
- return text;
448
+ async probeState(state) {
449
+ const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
450
+ return state.mode === 'chrome'
451
+ ? await this.probe('cdp', endpoint, CHROME_APPROVAL_TIMEOUT_MS)
452
+ : await this.probe('ws', endpoint);
671
453
  }
672
454
 
673
- parseVolumeEntry(value) {
674
- const parts = String(value || '').split(':');
675
- if (parts.length < 2) {
676
- return null;
677
- }
678
- return {
679
- hostPath: path.resolve(this.expandHomePath(parts[0])),
680
- containerPath: parts[1]
681
- };
682
- }
455
+ // ---------- 进程管理 ----------
683
456
 
684
- mergedRootVolumes() {
685
- return [
686
- ...asStringArray(this.rootGlobalConfig.volumes, []),
687
- ...asStringArray(this.rootRunConfig.volumes, []),
688
- ...asStringArray(this.globalConfig.volumes, []),
689
- ...asStringArray(this.runConfig.volumes, [])
690
- ];
457
+ spawnDetached(command, args, logPath) {
458
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
459
+ const logFd = fs.openSync(logPath, 'a', 0o600);
460
+ const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd] });
461
+ fs.closeSync(logFd);
462
+ child.unref();
463
+ return child;
691
464
  }
692
465
 
693
- hasRootVolume(required) {
694
- const parsedRequired = this.parseVolumeEntry(required);
695
- if (!parsedRequired) {
696
- return false;
466
+ async killGroup(pid, { verify = true } = {}) {
467
+ if (verify ? !pidIsOurs(pid) : !pidAlive(pid)) {
468
+ return;
469
+ }
470
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
471
+ try {
472
+ process.kill(-pid, signal);
473
+ } catch {
474
+ try {
475
+ process.kill(pid, signal);
476
+ } catch {
477
+ return;
478
+ }
479
+ }
480
+ for (let i = 0; i < 20; i += 1) {
481
+ if (!pidAlive(pid)) {
482
+ return;
483
+ }
484
+ // eslint-disable-next-line no-await-in-loop
485
+ await sleep(100);
486
+ }
697
487
  }
698
- return this.mergedRootVolumes().some(item => {
699
- const parsed = this.parseVolumeEntry(item);
700
- return parsed
701
- && parsed.hostPath === parsedRequired.hostPath
702
- && parsed.containerPath === parsedRequired.containerPath;
703
- });
704
488
  }
705
489
 
706
- cliHostContainerAttachConfigReady() {
707
- return this.config.cliSessionScene === 'cli-host-headed'
708
- && this.hasRootVolume('~/.manyoyo/.cache/ms-playwright:/root/.cache/ms-playwright');
490
+ async waitForPort(port, child) {
491
+ const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
492
+ while (Date.now() < deadline) {
493
+ if (child && child.exitCode !== null) {
494
+ return false;
495
+ }
496
+ // eslint-disable-next-line no-await-in-loop
497
+ if (await portReady(port)) {
498
+ return true;
499
+ }
500
+ // eslint-disable-next-line no-await-in-loop
501
+ await sleep(300);
502
+ }
503
+ return false;
709
504
  }
710
505
 
711
- devtoolsActivePortCandidates() {
712
- const homeDir = this.config.homeDir;
713
- const configured = this.config.devtoolsActivePortPath;
714
- return uniqueStrings([
715
- configured,
716
- path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'),
717
- path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
718
- path.join(homeDir, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
719
- path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
720
- path.join(homeDir, '.config', 'google-chrome', 'DevToolsActivePort'),
721
- path.join(homeDir, '.config', 'chromium', 'DevToolsActivePort'),
722
- path.join(homeDir, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
723
- path.join(homeDir, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
724
- path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
725
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
726
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
727
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome SxS', 'User Data', 'DevToolsActivePort'),
728
- path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data', 'DevToolsActivePort'),
729
- path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
730
- ]);
506
+ async stopCurrent(state) {
507
+ if (state.mode === 'vnc') {
508
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
509
+ } else if (state.mode === 'headed' || state.mode === 'chrome') {
510
+ await this.killGroup(state.pid);
511
+ }
731
512
  }
732
513
 
733
- parseDevToolsActivePort(content, filePath = 'DevToolsActivePort') {
734
- const lines = String(content || '')
735
- .split(/\r?\n/)
736
- .map(line => line.trim())
737
- .filter(Boolean);
738
- const port = Number(lines[0]);
739
- const wsPath = lines[1] || '';
514
+ // ---------- 各模式启动 ----------
740
515
 
741
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
742
- throw new Error(`${filePath} 内容无效: 第一行必须是端口号`);
516
+ startFailure(mode, message, extra = '') {
517
+ this.err(`[up] ${mode} 失败: ${message}`);
518
+ if (extra) {
519
+ this.err(extra);
743
520
  }
744
- if (!wsPath.startsWith('/devtools/browser/')) {
745
- throw new Error(`${filePath} 内容无效: 第二行必须是 /devtools/browser/...`);
746
- }
747
-
748
- return { port, wsPath };
521
+ return 1;
749
522
  }
750
523
 
751
- readDevToolsActivePort() {
752
- const candidates = this.devtoolsActivePortCandidates();
753
- for (const candidate of candidates) {
754
- if (!fs.existsSync(candidate)) {
755
- continue;
756
- }
757
- const content = fs.readFileSync(candidate, 'utf8');
758
- return {
759
- ...this.parseDevToolsActivePort(content, candidate),
760
- filePath: candidate
761
- };
524
+ async portPreflight(mode, port) {
525
+ if (await portReady(port)) {
526
+ this.startFailure(mode, `端口 ${port} 已被占用。`,
527
+ '换一个端口: 在 ~/.manyoyo/manyoyo.json 设置 "plugins": { "playwright": { "port": 9335 } },或先停掉占用它的进程。');
528
+ return false;
762
529
  }
763
-
764
- throw new Error(`未找到 DevToolsActivePort,请先在 Chrome 启用 remote debugging,并挂载或配置 devtoolsActivePortPath。已检查: ${candidates.join(', ')}`);
530
+ return true;
765
531
  }
766
532
 
767
- sceneInitScriptPath(sceneName) {
768
- const configFile = path.basename(this.sceneConfigPath(sceneName), '.json');
769
- return path.join(this.config.configDir, `${configFile}.init.js`);
533
+ async startHeaded(extensionPaths) {
534
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
535
+ return this.startFailure('headed', '当前没有图形界面(DISPLAY / WAYLAND_DISPLAY 为空)。', '没有桌面的机器请改用: manyoyo playwright up vnc');
536
+ }
537
+ const { port } = this.config;
538
+ if (!(await this.portPreflight('headed', port))) {
539
+ return 1;
540
+ }
541
+ try {
542
+ this.ensureBrowserInstalled();
543
+ } catch (error) {
544
+ return this.startFailure('headed', error.message);
545
+ }
546
+ const token = this.ensureToken();
547
+ const serverConfigPath = path.join(this.runDir, 'server.json');
548
+ // 优先开沙箱;系统禁止非特权用户命名空间(如 Ubuntu 23.10+ 的 AppArmor 限制)时退回无沙箱
549
+ let child = null;
550
+ for (const chromiumSandbox of [true, false]) {
551
+ writeConfigFileSecure(serverConfigPath, `${JSON.stringify(fingerprint.buildServerConfig({
552
+ host: headedListenHost(),
553
+ port,
554
+ wsPath: `/${token}`,
555
+ chromiumSandbox,
556
+ extensionArgs: extensions.buildExtensionLaunchArgs(extensionPaths),
557
+ ...this.profile()
558
+ }), null, 4)}\n`);
559
+ fs.rmSync(this.logPath(), { force: true });
560
+ child = this.spawnDetached(process.execPath,
561
+ [path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath());
562
+ if (await this.waitForPort(port, child)) {
563
+ break;
564
+ }
565
+ await this.killGroup(child.pid, { verify: false });
566
+ if (!chromiumSandbox || !tailText(this.logPath(), 200).includes('No usable sandbox')) {
567
+ return this.startFailure('headed', '浏览器服务没有启动成功。',
568
+ `${tailText(this.logPath(), 20)}\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`);
569
+ }
570
+ this.out('[up] 系统不允许非特权用户命名空间,Chromium 沙箱不可用,改为无沙箱启动。');
571
+ }
572
+ return { mode: 'headed', port, pid: child.pid, listenHost: headedListenHost(), extensionPaths };
573
+ }
574
+
575
+ async startChrome() {
576
+ const { port } = this.config;
577
+ const candidates = this.devtoolsCandidates();
578
+ const enableHint = '请在 Chrome 地址栏进入 chrome://inspect/#remote-debugging,勾选“Allow remote debugging”(Chrome 没开就先打开它),然后重试。\n'
579
+ + '(自定义用户目录时,用 plugins.playwright.devtoolsActivePortPath 指定 DevToolsActivePort 文件。)';
580
+ const target = findDevToolsActivePort(candidates);
581
+ if (!target) {
582
+ return this.startFailure('chrome', '没有找到开启了远程调试的 Chrome。', enableHint);
583
+ }
584
+ // Chrome 退出后会留下旧的 DevToolsActivePort,所以还要确认调试端口真的在监听
585
+ if (!(await portReady(target.port))) {
586
+ return this.startFailure('chrome', 'Chrome 没有在运行(只找到上次留下的调试端口记录)。', enableHint);
587
+ }
588
+ if (!(await this.portPreflight('chrome', port))) {
589
+ return 1;
590
+ }
591
+ fs.rmSync(this.logPath(), { force: true });
592
+ const relayConfigPath = path.join(this.runDir, 'relay.json');
593
+ writeConfigFileSecure(relayConfigPath, `${JSON.stringify({
594
+ host: defaultListenHost(), port, token: this.ensureToken(), candidates
595
+ }, null, 4)}\n`);
596
+ const child = this.spawnDetached(process.execPath,
597
+ [path.join(__dirname, 'playwright-relay.js'), relayConfigPath], this.logPath());
598
+ if (!(await this.waitForPort(port, child))) {
599
+ await this.killGroup(child.pid, { verify: false });
600
+ return this.startFailure('chrome', '中继没有启动成功。', tailText(this.logPath(), 20));
601
+ }
602
+ return { mode: 'chrome', port, pid: child.pid, listenHost: defaultListenHost() };
770
603
  }
771
604
 
772
- buildInitScriptContent() {
773
- const lines = [
774
- "'use strict';",
775
- '(function () {',
776
- ` const platformValue = ${JSON.stringify(this.config.navigatorPlatform)};`,
777
- ' try {',
778
- ' const navProto = Object.getPrototypeOf(navigator);',
779
- " Object.defineProperty(navProto, 'platform', {",
780
- ' configurable: true,',
781
- ' get: () => platformValue',
782
- ' });',
783
- ' } catch (_) {}'
605
+ devtoolsCandidates() {
606
+ const h = this.homeDir;
607
+ const local = path.join(h, 'AppData', 'Local');
608
+ return [
609
+ this.config.devtoolsActivePortPath,
610
+ path.join(h, 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'),
611
+ path.join(h, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
612
+ path.join(h, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
613
+ path.join(h, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
614
+ path.join(h, '.config', 'google-chrome', 'DevToolsActivePort'),
615
+ path.join(h, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
616
+ path.join(h, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
617
+ path.join(h, '.config', 'chromium', 'DevToolsActivePort'),
618
+ path.join(h, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
619
+ path.join(local, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
620
+ path.join(local, 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
621
+ path.join(local, 'Chromium', 'User Data', 'DevToolsActivePort'),
622
+ path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
623
+ ].filter(Boolean);
624
+ }
625
+
626
+ baseImageRef() {
627
+ const global = this.rootGlobalConfig;
628
+ const run = this.rootRunConfig;
629
+ const name = String(run.imageName || global.imageName || 'ghcr.io/xcanwin/manyoyo').trim();
630
+ const version = String(run.imageVersion || global.imageVersion || require('../../package.json').imageVersion).trim();
631
+ return { name, version, ref: `${name}:${version}` };
632
+ }
633
+
634
+ // vnc 容器直接用当前 manyoyo 镜像(已带 xvfb / x11vnc / noVNC),不需要额外构建
635
+ ensureBaseImage() {
636
+ const { ref } = this.baseImageRef();
637
+ if (this.runtimeCall(['image', 'inspect', '--format', '{{.Id}}', ref]).status !== 0) {
638
+ throw new Error(`镜像 ${ref} 不存在,请先运行一次 manyoyo run 拉取或构建镜像。`);
639
+ }
640
+ return ref;
641
+ }
642
+
643
+ async startVnc(extensionPaths) {
644
+ const { port, vncPort, novncPort } = this.config;
645
+ for (const item of [port, vncPort, novncPort]) {
646
+ if (await portReady(item)) {
647
+ return this.startFailure('vnc', `端口 ${item} 已被占用。`,
648
+ '在 ~/.manyoyo/manyoyo.json 的 plugins.playwright 里改 port / vncPort / novncPort,或先停掉占用它的进程。');
649
+ }
650
+ }
651
+ let image;
652
+ try {
653
+ image = this.ensureBaseImage();
654
+ } catch (error) {
655
+ return this.startFailure('vnc', error.message);
656
+ }
657
+ const token = this.ensureToken();
658
+ const password = this.ensureVncPassword();
659
+ const mountDir = path.join(this.runDir, 'vnc');
660
+ fs.mkdirSync(mountDir, { recursive: true, mode: 0o700 });
661
+ const mounts = extensions.buildContainerExtensionMounts(extensionPaths);
662
+ for (const name of ['vnc-entry.sh', 'playwright-server.js']) {
663
+ const source = path.join(name === 'vnc-entry.sh' ? VNC_ASSET_DIR : __dirname, name);
664
+ writeConfigFileSecure(path.join(mountDir, name), fs.readFileSync(source, 'utf8'));
665
+ }
666
+ writeConfigFileSecure(path.join(mountDir, 'server.json'), `${JSON.stringify(fingerprint.buildServerConfig({
667
+ host: '0.0.0.0',
668
+ port,
669
+ wsPath: `/${token}`,
670
+ window: 'virtual',
671
+ chromiumSandbox: false,
672
+ extensionArgs: extensions.buildExtensionLaunchArgs(mounts.containerPaths),
673
+ ...this.profile()
674
+ }), null, 4)}\n`);
675
+ const envFile = path.join(mountDir, 'env');
676
+ const noProxy = appendNoProxy(process.env.NO_PROXY ?? process.env.no_proxy);
677
+ writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n`);
678
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
679
+ const listenHost = defaultListenHost();
680
+ const args = [
681
+ 'run', '-d', '--name', VNC_CONTAINER,
682
+ '--env-file', envFile,
683
+ '--publish', `${listenHost}:${port}:${port}`,
684
+ '--publish', `127.0.0.1:${vncPort}:5900`,
685
+ '--publish', `127.0.0.1:${novncPort}:6080`,
686
+ '--volume', `${mountDir}:${VNC_MOUNT_DIR}:ro`,
687
+ ...mounts.volumes.flatMap(volume => ['--volume', volume]),
688
+ '--entrypoint', 'sh',
689
+ image, `${VNC_MOUNT_DIR}/vnc-entry.sh`
784
690
  ];
785
-
786
- if (this.config.disableWebRTC) {
787
- lines.push(
788
- ' try {',
789
- ' const scope = globalThis;',
790
- " const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];",
791
- ' for (const name of blocked) {',
792
- " Object.defineProperty(scope, name, { configurable: true, writable: true, value: undefined });",
793
- ' }',
794
- ' if (navigator.mediaDevices) {',
795
- ' const errorFactory = () => {',
796
- ' try {',
797
- " return new DOMException('WebRTC is disabled', 'NotAllowedError');",
798
- ' } catch (_) {',
799
- " const error = new Error('WebRTC is disabled');",
800
- " error.name = 'NotAllowedError';",
801
- ' return error;',
802
- ' }',
803
- ' };',
804
- " Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
805
- ' configurable: true,',
806
- ' writable: true,',
807
- ' value: async () => { throw errorFactory(); }',
808
- ' });',
809
- ' }',
810
- ' } catch (_) {}'
811
- );
691
+ const run = this.runtimeCall(args);
692
+ if (run.status !== 0) {
693
+ return this.startFailure('vnc', '容器启动失败。', run.stderr.trim());
694
+ }
695
+ // 发布端口由运行时转发,容器没起来时 TCP 也能连上,所以以浏览器服务打印的 ready 为准
696
+ const isReady = () => /^ready$/m.test(this.runtimeCall(['logs', '--tail', '200', VNC_CONTAINER]).stdout);
697
+ let started = false;
698
+ for (let i = 0; i < 100 && !started; i += 1) {
699
+ started = isReady();
700
+ if (!started) {
701
+ // eslint-disable-next-line no-await-in-loop
702
+ await sleep(300);
703
+ }
812
704
  }
813
-
814
- lines.push('})();', '');
815
- return lines.join('\n');
705
+ if (!started) {
706
+ const logs = this.runtimeCall(['logs', '--tail', '30', VNC_CONTAINER]);
707
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
708
+ return this.startFailure('vnc', '浏览器服务没有启动成功。', `${logs.stdout}${logs.stderr}`.trim());
709
+ }
710
+ return { mode: 'vnc', port, container: VNC_CONTAINER, listenHost, novncPort, vncPort, extensionPaths };
816
711
  }
817
712
 
818
- ensureSceneInitScript(sceneName) {
819
- const filePath = this.sceneInitScriptPath(sceneName);
820
- const content = this.buildInitScriptContent();
821
- fs.writeFileSync(filePath, content, 'utf8');
822
- return filePath;
713
+ novncUrl() {
714
+ return `http://127.0.0.1:${this.config.novncPort}/vnc.html?autoconnect=1&resize=scale&password=${this.readPersisted('vnc-password')}`;
823
715
  }
824
716
 
825
- defaultBrowserName(sceneName) {
826
- if (isCliScene(sceneName)) {
827
- return 'chromium';
717
+ // ---------- 命令 ----------
718
+
719
+ printOverview() {
720
+ this.out('Playwright 浏览器模式(同一时间只有一个):');
721
+ for (const [name, desc] of MODE_HELP) {
722
+ this.out(` ${name.padEnd(8)}${desc}`);
828
723
  }
829
- const cfg = this.buildSceneConfig(sceneName);
830
- const browserName = cfg && cfg.browser && cfg.browser.browserName;
831
- return String(browserName || 'chromium');
724
+ this.out('');
725
+ this.out('用法: manyoyo playwright up <headed|chrome|vnc> 切换模式');
726
+ this.out(' manyoyo playwright down 回到默认模式');
727
+ this.out(' manyoyo playwright status 查看当前模式与真实可用性');
832
728
  }
833
729
 
834
- ensureContainerScenePrerequisites(sceneName) {
835
- if (!this.sceneConfigMissing(sceneName)) {
730
+ printUsage(state) {
731
+ this.out('');
732
+ this.out('容器内 Agent: 自动生效,直接 manyoyo run;已打开浏览器的会话先执行 playwright-cli close 再重新 open。');
733
+ if (state.mode === 'default') {
836
734
  return;
837
735
  }
838
- const tag = String(this.config.dockerTag || 'latest').trim() || 'latest';
839
- const image = `mcr.microsoft.com/playwright/mcp:${tag}`;
840
- this.runCmd([this.config.containerRuntime, 'pull', image], { check: true });
736
+ const configPath = this.userDisplayPath(this.writeHostConfig(state));
737
+ this.out(`宿主机上的 Agent: PLAYWRIGHT_MCP_CONFIG=${shellValue(configPath)} claude`);
738
+ if (state.mode === 'vnc') {
739
+ this.out(`noVNC 观看: ${this.novncUrl()}`);
740
+ this.out(`原生 VNC: 127.0.0.1:${state.vncPort}(密码见 manyoyo playwright status)`);
741
+ }
742
+ if (state.mode === 'chrome') {
743
+ this.out('⚠️ 风险: 容器内 Agent 能操作你 Chrome 里已登录的所有网站。Chrome 每次新连接(每次 playwright-cli open、每次 status)都会弹出“要允许远程调试吗?”,请点“允许”。');
744
+ }
745
+ if (state.mode !== 'default' && !(process.platform === 'darwin' && state.mode !== 'headed')) {
746
+ this.out(`安全: 端口 ${state.port} 监听 0.0.0.0,仅靠 token 保护;建议用防火墙限制来源。`);
747
+ }
841
748
  }
842
749
 
843
- ensureHostScenePrerequisites(sceneName) {
844
- if (!isCliScene(sceneName) && !this.sceneConfigMissing(sceneName)) {
845
- return;
750
+ async up(mode, { extensionPaths = [], extensionNames = [] } = {}) {
751
+ if (!MODES.includes(mode)) {
752
+ this.printOverview();
753
+ if (mode) {
754
+ this.err('');
755
+ this.err(`未知模式: ${mode},可用: ${MODES.join(', ')}`);
756
+ }
757
+ return 1;
758
+ }
759
+ const extPaths = extensions.resolveExtensionInputs(this.root, { extensionPaths, extensionNames });
760
+ if (extPaths.length > 0 && mode === 'chrome') {
761
+ this.err('chrome 模式控制的是你自己的 Chrome,不支持 --ext-path / --ext-name;扩展只支持 headed、vnc。');
762
+ return 1;
763
+ }
764
+ this.ensureDirs();
765
+ await this.stopCurrent(this.readState());
766
+ this.writeState({ mode: 'default' });
767
+
768
+ const started = mode === 'headed' ? await this.startHeaded(extPaths)
769
+ : mode === 'chrome' ? await this.startChrome()
770
+ : await this.startVnc(extPaths);
771
+ if (typeof started === 'number') {
772
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
773
+ return started;
774
+ }
775
+ const state = { ...started, startedAt: new Date().toISOString() };
776
+ this.writeState(state);
777
+ this.writeCurrentConfig(state, this.runtimeCommand());
778
+
779
+ if (mode === 'chrome') {
780
+ this.out('等待 Chrome 确认:请在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(最多等 60 秒)…');
781
+ }
782
+ const result = await this.probeState(state);
783
+ if (!result.ok) {
784
+ if (mode === 'chrome') {
785
+ this.err('[up] chrome 失败: 连不上 Chrome 的调试接口。');
786
+ this.err('请确认已在 chrome://inspect/#remote-debugging 开启远程调试,并在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(60 秒内),然后重试。');
787
+ } else {
788
+ this.err(`[up] ${mode} 失败: 浏览器服务已启动,但打不开页面(${result.error})。`);
789
+ this.err(tailText(this.logPath(), 10));
790
+ }
791
+ // 回滚:不让坏掉的模式留在配置里
792
+ await this.stopCurrent(state);
793
+ this.writeState({ mode: 'default' });
794
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
795
+ this.err('已回到默认模式。');
796
+ return 1;
846
797
  }
847
- this.runCmd([this.playwrightBinPath(sceneName), 'install', '--with-deps', this.defaultBrowserName(sceneName)], { check: true });
798
+ this.out(`✅ ${mode} 模式已就绪(真实探测通过,${result.ms}ms)`);
799
+ this.printUsage(state);
800
+ return 0;
848
801
  }
849
802
 
850
- scenePidFile(sceneName) {
851
- return path.join(this.config.runDir, `${sceneName}.pid`);
803
+ runtimeCommand() {
804
+ try {
805
+ return this.resolveRuntime().command;
806
+ } catch {
807
+ return 'docker';
808
+ }
852
809
  }
853
810
 
854
- sceneLogFile(sceneName) {
855
- return path.join(this.config.runDir, `${sceneName}.log`);
811
+ async down() {
812
+ const state = this.readState();
813
+ await this.stopCurrent(state);
814
+ this.ensureDirs();
815
+ fs.rmSync(this.tokenPath(), { force: true });
816
+ fs.rmSync(this.hostConfigPath(), { force: true });
817
+ this.writeState({ mode: 'default' });
818
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
819
+ this.out(state.mode === 'default'
820
+ ? '当前已是默认模式(容器内虚拟屏里的有头浏览器)。'
821
+ : `已停止 ${state.mode} 模式,回到默认模式;已打开浏览器的容器会话先 playwright-cli close 再重新 open。`);
822
+ return 0;
856
823
  }
857
824
 
858
- localBinPath(binName) {
859
- const filename = process.platform === 'win32' ? `${binName}.cmd` : binName;
860
- const binPath = path.join(this.projectRoot, 'node_modules', '.bin', filename);
861
- if (!fs.existsSync(binPath)) {
862
- throw new Error(`local binary not found: ${binPath}. Run npm install first.`);
825
+ // 配置文件被删(或整个目录被删)时重建;返回是否重建过
826
+ ensureCurrentFiles(state) {
827
+ const missing = ['config.json', 'stealth.init.js'].some(name => !fs.existsSync(path.join(this.currentDir, name)));
828
+ if (missing) {
829
+ this.writeCurrentConfig(state, this.runtimeCommand());
863
830
  }
864
- return binPath;
831
+ return missing;
865
832
  }
866
833
 
867
- playwrightBinPath(sceneName) {
868
- if (!isCliScene(sceneName)) {
869
- return this.localBinPath('playwright');
834
+ async status() {
835
+ let state = this.readState();
836
+ if (this.ensureCurrentFiles(state)) {
837
+ this.out('[status] 容器内浏览器的配置文件缺失,已重建;运行中的容器会自动恢复(整个 ~/.manyoyo/plugin/playwright 目录被删过的容器需要重建)。');
870
838
  }
871
-
872
- const filename = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
873
- const candidates = [
874
- path.join(this.projectRoot, 'node_modules', '@playwright', 'mcp', 'node_modules', '.bin', filename),
875
- path.join(this.projectRoot, 'node_modules', '.bin', filename)
876
- ];
877
-
878
- for (const candidate of candidates) {
879
- if (fs.existsSync(candidate)) {
880
- return candidate;
839
+ if (state.mode === 'default') {
840
+ this.out('当前模式: default(容器内虚拟屏里的有头浏览器,无需宿主机进程)');
841
+ this.out('想换模式: manyoyo playwright 查看全部模式');
842
+ return 0;
843
+ }
844
+ this.out(`当前模式: ${state.mode}(端口 ${state.port}${state.pid ? `,pid ${state.pid}` : ''})`);
845
+ const alive = await this.modeAlive(state);
846
+ const result = alive ? await this.probeState(state) : { ok: false, error: state.mode === 'vnc' ? 'vnc 容器没有在运行' : '浏览器服务进程已不在' };
847
+ if (result.ok) {
848
+ this.out(`真实探测: 通过(${result.ms}ms)`);
849
+ if (state.mode === 'vnc') {
850
+ this.out(`noVNC: ${this.novncUrl()}`);
881
851
  }
852
+ return 0;
882
853
  }
883
-
884
- throw new Error(`local binary not found for ${sceneName}. Run npm install first.`);
885
- }
886
-
887
- extensionDirPath() {
888
- return path.join(this.config.homeDir, '.manyoyo', 'plugin', 'playwright', 'extensions');
854
+ this.err(`真实探测: 失败(${result.error})`);
855
+ state = { mode: 'default' };
856
+ this.writeState(state);
857
+ this.writeCurrentConfig(state, this.runtimeCommand());
858
+ this.err('已把容器内浏览器回退到默认模式。恢复: manyoyo playwright up <headed|chrome|vnc>;排查: manyoyo playwright logs');
859
+ return 1;
889
860
  }
890
861
 
891
- extensionTmpDirPath() {
892
- return path.join(this.config.homeDir, '.manyoyo', 'plugin', 'playwright', 'tmp-crx');
862
+ logs() {
863
+ const state = this.readState();
864
+ if (state.mode === 'vnc') {
865
+ const logs = this.runtimeCall(['logs', '--tail', '80', VNC_CONTAINER]);
866
+ this.out(`${logs.stdout}${logs.stderr}`.trimEnd() || '[logs] 没有日志');
867
+ return logs.status === 0 ? 0 : 1;
868
+ }
869
+ this.out(tailText(this.logPath(), 80).trimEnd() || '[logs] 没有日志');
870
+ return 0;
893
871
  }
894
872
 
895
- cliBrowserCacheDirPath() {
896
- return path.join(this.config.homeDir, '.manyoyo', '.cache', 'ms-playwright');
873
+ // 想用 MCP 的 Agent:在容器内以 stdio 注册,读同一个 $PLAYWRIGHT_MCP_CONFIG,自动跟随当前模式
874
+ mcpAdd() {
875
+ this.out('# 在容器内执行(playwright-mcp 随当前模式自动切换,无需重新注册)');
876
+ this.out('claude mcp add -s user playwright -- playwright-mcp');
877
+ this.out('codex mcp add playwright -- playwright-mcp');
878
+ this.out('gemini mcp add -s user playwright playwright-mcp');
879
+ return 0;
897
880
  }
898
881
 
899
- ensureCliHostHeadedCacheDir(sceneName) {
900
- if (sceneName !== 'cli-host-headed') {
901
- return;
902
- }
903
- fs.mkdirSync(this.cliBrowserCacheDirPath(), { recursive: true });
904
- }
905
-
906
- resolveTargets(sceneName = 'all') {
907
- const requested = String(sceneName || 'all').trim();
908
- const enabledSet = new Set(this.config.enabledScenes);
909
- const runtime = this.config.runtime;
910
-
911
- const isAllowedByRuntime = (scene) => {
912
- const type = SCENE_DEFS[scene].type;
913
- if (runtime === 'mixed') {
914
- return true;
915
- }
916
- return runtime === type;
917
- };
918
-
919
- if (requested !== 'all') {
920
- if (!SCENE_DEFS[requested]) {
921
- throw new Error(`未知场景: ${requested}`);
922
- }
923
- if (!enabledSet.has(requested)) {
924
- throw new Error(`场景未启用: ${requested}`);
925
- }
926
- if (!isAllowedByRuntime(requested)) {
927
- throw new Error(`当前 runtime=${runtime},不允许场景: ${requested}`);
928
- }
929
- return [requested];
930
- }
931
-
932
- return SCENE_ORDER
933
- .filter(scene => enabledSet.has(scene))
934
- .filter(scene => isAllowedByRuntime(scene));
935
- }
936
-
937
- resolveExtensionPaths(extensionArgs = []) {
938
- const inputs = asStringArray(extensionArgs, []);
939
- const uniquePaths = [];
940
- const seen = new Set();
941
-
942
- for (const item of inputs) {
943
- const absPath = path.resolve(item);
944
- if (!fs.existsSync(absPath)) {
945
- throw new Error(`扩展路径不存在: ${absPath}`);
946
- }
947
- const stat = fs.statSync(absPath);
948
- if (!stat.isDirectory()) {
949
- throw new Error(`扩展路径必须是目录: ${absPath}`);
950
- }
951
-
952
- const manifestPath = path.join(absPath, 'manifest.json');
953
- if (fs.existsSync(manifestPath)) {
954
- if (!seen.has(absPath)) {
955
- seen.add(absPath);
956
- uniquePaths.push(absPath);
957
- }
958
- continue;
959
- }
960
-
961
- const children = fs.readdirSync(absPath, { withFileTypes: true })
962
- .filter(dirent => dirent.isDirectory())
963
- .map(dirent => path.join(absPath, dirent.name))
964
- .filter(child => fs.existsSync(path.join(child, 'manifest.json')));
965
-
966
- if (children.length === 0) {
967
- throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
968
- }
969
-
970
- for (const childPath of children) {
971
- if (!seen.has(childPath)) {
972
- seen.add(childPath);
973
- uniquePaths.push(childPath);
974
- }
975
- }
976
- }
977
-
978
- return uniquePaths;
979
- }
980
-
981
- resolveNamedExtensionPaths(extensionNames = []) {
982
- const names = asStringArray(extensionNames, []);
983
- const extensionRoot = path.resolve(this.extensionDirPath());
984
-
985
- return names.map(name => {
986
- if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
987
- throw new Error(`扩展名称无效: ${name}`);
988
- }
989
- return path.join(extensionRoot, name);
990
- });
991
- }
992
-
993
- resolveExtensionInputs(options = {}) {
994
- const extensionPaths = asStringArray(options.extensionPaths, []);
995
- const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
996
- return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
997
- }
998
-
999
- buildExtensionLaunchArgs(extensionPaths) {
1000
- const joined = extensionPaths.join(',');
1001
- return [
1002
- `--disable-extensions-except=${joined}`,
1003
- `--load-extension=${joined}`
1004
- ];
1005
- }
1006
-
1007
- sanitizeExtensionMountName(value) {
1008
- const sanitized = String(value || '')
1009
- .trim()
1010
- .replace(/[^A-Za-z0-9._-]/g, '-')
1011
- .replace(/-+/g, '-')
1012
- .replace(/^-|-$/g, '');
1013
- return sanitized || 'ext';
1014
- }
1015
-
1016
- buildContainerExtensionMounts(extensionPaths = []) {
1017
- const hostPaths = asStringArray(extensionPaths, []);
1018
- const containerPaths = [];
1019
- const volumeMounts = [];
1020
-
1021
- hostPaths.forEach((hostPath, idx) => {
1022
- const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
1023
- const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
1024
- containerPaths.push(containerPath);
1025
- volumeMounts.push(`${hostPath}:${containerPath}:ro`);
1026
- });
1027
-
1028
- return { containerPaths, volumeMounts };
1029
- }
1030
-
1031
- baseLaunchArgs() {
1032
- return [
1033
- `--user-agent=${DEFAULT_FINGERPRINT_PROFILE.userAgent}`,
1034
- `--lang=${DEFAULT_FINGERPRINT_PROFILE.locale}`,
1035
- `--window-size=${DEFAULT_FINGERPRINT_PROFILE.width},${DEFAULT_FINGERPRINT_PROFILE.height}`,
1036
- '--disable-blink-features=AutomationControlled',
1037
- '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
1038
- ];
1039
- }
1040
-
1041
- buildMcpSceneConfig(sceneName, options = {}) {
1042
- const def = SCENE_DEFS[sceneName];
1043
- const port = this.scenePort(sceneName);
1044
- const extensionPaths = asStringArray(options.extensionPaths, []);
1045
- const initScript = asStringArray(options.initScript, []);
1046
- const launchOptions = {
1047
- channel: 'chromium',
1048
- headless: def.headless,
1049
- args: [...this.baseLaunchArgs()]
1050
- };
1051
-
1052
- if (extensionPaths.length > 0) {
1053
- launchOptions.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
1054
- }
1055
- if (this.config.disableWebRTC) {
1056
- launchOptions.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
1057
- }
1058
-
1059
- const contextOptions = {
1060
- userAgent: DEFAULT_FINGERPRINT_PROFILE.userAgent,
1061
- locale: DEFAULT_FINGERPRINT_PROFILE.locale,
1062
- timezoneId: DEFAULT_FINGERPRINT_PROFILE.timezoneId,
1063
- extraHTTPHeaders: {
1064
- 'Accept-Language': DEFAULT_FINGERPRINT_PROFILE.acceptLanguage
1065
- }
1066
- };
1067
- if (sceneName !== 'mcp-host-headed') {
1068
- contextOptions.viewport = {
1069
- width: DEFAULT_FINGERPRINT_PROFILE.width,
1070
- height: DEFAULT_FINGERPRINT_PROFILE.height
1071
- };
1072
- contextOptions.screen = {
1073
- width: DEFAULT_FINGERPRINT_PROFILE.width,
1074
- height: DEFAULT_FINGERPRINT_PROFILE.height
1075
- };
1076
- }
1077
-
1078
- return {
1079
- outputDir: '/tmp/.playwright-mcp',
1080
- server: {
1081
- host: def.listenHost,
1082
- port,
1083
- allowedHosts: [
1084
- `localhost:${port}`,
1085
- `127.0.0.1:${port}`,
1086
- `host.docker.internal:${port}`,
1087
- `host.containers.internal:${port}`
1088
- ]
1089
- },
1090
- browser: {
1091
- chromiumSandbox: true,
1092
- browserName: 'chromium',
1093
- initScript,
1094
- launchOptions,
1095
- contextOptions
1096
- }
1097
- };
1098
- }
1099
-
1100
- buildCliSceneConfig(sceneName, options = {}) {
1101
- const def = SCENE_DEFS[sceneName];
1102
- const extensionPaths = asStringArray(options.extensionPaths, []);
1103
- const payload = {
1104
- host: def.listenHost,
1105
- port: this.scenePort(sceneName),
1106
- wsPath: `/${sceneName}-${crypto.randomBytes(8).toString('hex')}`,
1107
- headless: def.headless,
1108
- channel: 'chromium',
1109
- chromiumSandbox: true,
1110
- args: [...this.baseLaunchArgs()]
1111
- };
1112
-
1113
- if (extensionPaths.length > 0) {
1114
- payload.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
1115
- }
1116
- if (this.config.disableWebRTC) {
1117
- payload.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
1118
- }
1119
-
1120
- return payload;
1121
- }
1122
-
1123
- buildSceneConfig(sceneName, options = {}) {
1124
- if (isCliScene(sceneName)) {
1125
- return this.buildCliSceneConfig(sceneName, options);
1126
- }
1127
- return this.buildMcpSceneConfig(sceneName, options);
1128
- }
1129
-
1130
- ensureSceneConfig(sceneName, options = {}) {
1131
- fs.mkdirSync(this.config.configDir, { recursive: true });
1132
- if (isCliScene(sceneName)) {
1133
- const payload = this.buildCliSceneConfig(sceneName, options);
1134
- const filePath = this.sceneConfigPath(sceneName);
1135
- fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
1136
- return filePath;
1137
- }
1138
- const initScriptPath = this.ensureSceneInitScript(sceneName);
1139
- const configuredInitScript = asStringArray(options.initScript, []);
1140
- const initScript = configuredInitScript.length > 0 ? configuredInitScript : [initScriptPath];
1141
- const payload = this.buildSceneConfig(sceneName, {
1142
- ...options,
1143
- initScript
1144
- });
1145
- const filePath = this.sceneConfigPath(sceneName);
1146
- fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
1147
- return filePath;
1148
- }
1149
-
1150
- async portReady(port) {
1151
- return await new Promise((resolve) => {
1152
- let settled = false;
1153
- const socket = net.createConnection({ host: '127.0.0.1', port });
1154
-
1155
- const finish = (value) => {
1156
- if (settled) {
1157
- return;
1158
- }
1159
- settled = true;
1160
- socket.destroy();
1161
- resolve(value);
1162
- };
1163
-
1164
- socket.setTimeout(300);
1165
- socket.once('connect', () => finish(true));
1166
- socket.once('timeout', () => finish(false));
1167
- socket.once('error', () => finish(false));
1168
- });
1169
- }
1170
-
1171
- async waitForPort(port) {
1172
- for (let i = 0; i < 60; i += 1) {
1173
- // eslint-disable-next-line no-await-in-loop
1174
- if (await this.portReady(port)) {
1175
- return true;
1176
- }
1177
- // eslint-disable-next-line no-await-in-loop
1178
- await sleep(500);
1179
- }
1180
- return false;
1181
- }
1182
-
1183
- containerEnv(sceneName, cfgPath, options = {}) {
1184
- const def = SCENE_DEFS[sceneName];
1185
- const requireVncPassword = options.requireVncPassword === true;
1186
- const env = {
1187
- ...process.env,
1188
- PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
1189
- PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
1190
- PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
1191
- PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
1192
- PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
1193
- PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.mcpContHeadedNoVnc)
1194
- };
1195
-
1196
- if (sceneName === 'mcp-cont-headed') {
1197
- const envKey = this.config.vncPasswordEnvKey;
1198
- let password = process.env[envKey];
1199
- if (!password) {
1200
- password = this.randomAlnum(16);
1201
- if (requireVncPassword) {
1202
- this.writeStdout(`[up] mcp-cont-headed ${envKey} not set; generated random 16-char password: ${password}`);
1203
- }
1204
- }
1205
- env.VNC_PASSWORD = password;
1206
- }
1207
-
1208
- return env;
1209
- }
1210
-
1211
- containerComposePath(sceneName) {
1212
- const def = SCENE_DEFS[sceneName];
1213
- return path.join(this.config.composeDir, def.composeFile);
1214
- }
1215
-
1216
- sceneComposeOverridePath(sceneName) {
1217
- return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
1218
- }
1219
-
1220
- ensureContainerComposeOverride(sceneName, volumeMounts = []) {
1221
- const overridePath = this.sceneComposeOverridePath(sceneName);
1222
- if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
1223
- fs.rmSync(overridePath, { force: true });
1224
- return '';
1225
- }
1226
-
1227
- fs.mkdirSync(this.config.runDir, { recursive: true });
1228
- const lines = [
1229
- 'services:',
1230
- ' playwright:',
1231
- ' volumes:'
1232
- ];
1233
- volumeMounts.forEach(item => {
1234
- lines.push(` - ${JSON.stringify(String(item))}`);
1235
- });
1236
- fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
1237
- return overridePath;
1238
- }
1239
-
1240
- buildCliSessionIntegration(dockerCmd) {
1241
- const sceneName = this.config.cliSessionScene;
1242
- if (!sceneName) {
1243
- return { envEntries: [], extraArgs: [], volumeEntries: [] };
1244
- }
1245
-
1246
- if (isDevScene(sceneName)) {
1247
- return this.buildDevHostCliSessionIntegration(dockerCmd, sceneName);
1248
- }
1249
-
1250
- const endpoint = this.readSceneEndpoint(sceneName);
1251
- if (!endpoint || !Number.isInteger(endpoint.port) || endpoint.port <= 0 || typeof endpoint.wsPath !== 'string' || !endpoint.wsPath) {
1252
- return { envEntries: [], extraArgs: [], volumeEntries: [] };
1253
- }
1254
-
1255
- const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1256
- const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1257
- const remoteEndpoint = `ws://${connectHost}:${endpoint.port}${endpoint.wsPath}`;
1258
- const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
1259
- const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
1260
- this.writeSceneCliAttachConfig(sceneName, {
1261
- outputDir: '/tmp/.playwright-cli',
1262
- browser: {
1263
- // launch-server(非 shared)不预创建 context;remoteEndpoint 存在时
1264
- // playwright-cli 默认 isolated=false 会走 contexts()[0] 并报
1265
- // "unable to connect to a browser that does not have any contexts",
1266
- // 因此容器内会话必须以 isolated 模式自建 context。
1267
- remoteEndpoint,
1268
- isolated: true
1269
- }
1270
- });
1271
- const envEntries = [
1272
- `PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
1273
- ];
1274
- const extraArgs = normalizedDockerCmd === 'docker'
1275
- ? ['--add-host', 'host.docker.internal:host-gateway']
1276
- : [];
1277
- const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
1278
- return { envEntries, extraArgs, volumeEntries };
1279
- }
1280
-
1281
- buildDevHostCliSessionIntegration(dockerCmd, sceneName) {
1282
- const endpoint = this.readDevToolsActivePort();
1283
- const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1284
- const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1285
- const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
1286
- const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
1287
-
1288
- this.writeSceneCliAttachConfig(sceneName, this.buildDevHostAttachConfig(endpoint, connectHost));
1289
-
1290
- const envEntries = [
1291
- `PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
1292
- ];
1293
- const extraArgs = normalizedDockerCmd === 'docker'
1294
- ? ['--add-host', 'host.docker.internal:host-gateway']
1295
- : [];
1296
- const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
1297
- return { envEntries, extraArgs, volumeEntries };
1298
- }
1299
-
1300
- async startContainer(sceneName, options = {}) {
1301
- const runtime = this.config.containerRuntime;
1302
- if (!this.ensureCommandAvailable(runtime)) {
1303
- this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
1304
- return 1;
1305
- }
1306
-
1307
- try {
1308
- this.ensureContainerScenePrerequisites(sceneName);
1309
- } catch (error) {
1310
- return error.returncode || 1;
1311
- }
1312
-
1313
- const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
1314
- const hostInitScriptPath = this.sceneInitScriptPath(sceneName);
1315
- const containerInitScriptPath = path.posix.join('/app/config', path.basename(hostInitScriptPath));
1316
- let configOptions = {
1317
- ...options,
1318
- extensionPaths: incomingExtensionPaths,
1319
- initScript: [containerInitScriptPath]
1320
- };
1321
- const composeFiles = [this.containerComposePath(sceneName)];
1322
- const volumeMounts = [`${hostInitScriptPath}:${containerInitScriptPath}:ro`];
1323
-
1324
- if (incomingExtensionPaths.length > 0) {
1325
- const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
1326
- volumeMounts.push(...mapped.volumeMounts);
1327
- configOptions = {
1328
- ...options,
1329
- extensionPaths: mapped.containerPaths,
1330
- initScript: [containerInitScriptPath]
1331
- };
1332
- }
1333
- const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
1334
- const overridePath = this.ensureContainerComposeOverride(sceneName, volumeMounts);
1335
- if (overridePath) {
1336
- composeFiles.push(overridePath);
1337
- }
1338
-
1339
- const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
1340
- const def = SCENE_DEFS[sceneName];
1341
-
1342
- try {
1343
- const args = [
1344
- runtime,
1345
- 'compose',
1346
- '-p',
1347
- def.projectName
1348
- ];
1349
- composeFiles.forEach(filePath => {
1350
- args.push('-f', filePath);
1351
- });
1352
- args.push('up', '-d');
1353
- this.runCmd(args, { env, check: true });
1354
- } catch (error) {
1355
- return error.returncode || 1;
1356
- }
1357
-
1358
- const port = this.scenePort(sceneName);
1359
- if (await this.waitForPort(port)) {
1360
- this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
1361
- return 0;
1362
- }
1363
-
1364
- this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
1365
- return 1;
1366
- }
1367
-
1368
- stopContainer(sceneName) {
1369
- const runtime = this.config.containerRuntime;
1370
- if (!this.ensureCommandAvailable(runtime)) {
1371
- this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
1372
- return 1;
1373
- }
1374
-
1375
- this.ensureContainerComposeOverride(sceneName, []);
1376
- const cfgPath = this.sceneConfigPath(sceneName);
1377
- const env = this.containerEnv(sceneName, cfgPath);
1378
- const def = SCENE_DEFS[sceneName];
1379
-
1380
- try {
1381
- this.runCmd([
1382
- runtime,
1383
- 'compose',
1384
- '-p',
1385
- def.projectName,
1386
- '-f',
1387
- this.containerComposePath(sceneName),
1388
- 'down'
1389
- ], { env, check: true });
1390
- } catch (error) {
1391
- return error.returncode || 1;
1392
- }
1393
-
1394
- this.writeStdout(`[down] ${sceneName}`);
1395
- return 0;
1396
- }
1397
-
1398
- statusContainer(sceneName) {
1399
- const runtime = this.config.containerRuntime;
1400
- if (!this.ensureCommandAvailable(runtime)) {
1401
- this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
1402
- return 1;
1403
- }
1404
-
1405
- const def = SCENE_DEFS[sceneName];
1406
- const cp = this.runCmd([
1407
- runtime,
1408
- 'ps',
1409
- '--filter',
1410
- `name=${def.containerName}`,
1411
- '--format',
1412
- '{{.Names}}'
1413
- ], { captureOutput: true, check: false });
1414
-
1415
- const names = new Set(
1416
- cp.stdout
1417
- .split(/\r?\n/)
1418
- .map(line => line.trim())
1419
- .filter(Boolean)
1420
- );
1421
-
1422
- if (names.has(def.containerName)) {
1423
- this.writeStdout(`[status] ${sceneName} running`);
1424
- } else {
1425
- this.writeStdout(`[status] ${sceneName} stopped`);
1426
- }
1427
- return 0;
1428
- }
1429
-
1430
- logsContainer(sceneName) {
1431
- const runtime = this.config.containerRuntime;
1432
- if (!this.ensureCommandAvailable(runtime)) {
1433
- this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
1434
- return 1;
1435
- }
1436
-
1437
- const def = SCENE_DEFS[sceneName];
1438
- const cp = this.runCmd([
1439
- runtime,
1440
- 'logs',
1441
- '--tail',
1442
- '80',
1443
- def.containerName
1444
- ], { captureOutput: true, check: false });
1445
-
1446
- const output = cp.stdout || cp.stderr;
1447
- if (output.trim()) {
1448
- this.writeStdout(output.trimEnd());
1449
- } else {
1450
- this.writeStdout(`[logs] ${sceneName} no logs`);
1451
- }
1452
-
1453
- return cp.returncode === 0 ? 0 : 1;
1454
- }
1455
-
1456
- hostLaunchCommand(sceneName, cfgPath) {
1457
- if (isCliScene(sceneName)) {
1458
- return {
1459
- command: this.playwrightBinPath(sceneName),
1460
- args: ['launch-server', '--browser', this.defaultBrowserName(sceneName), '--config', String(cfgPath)]
1461
- };
1462
- }
1463
- return {
1464
- command: this.localBinPath('playwright-mcp'),
1465
- args: ['--config', String(cfgPath)]
1466
- };
1467
- }
1468
-
1469
- spawnHostProcess(command, args, logFd) {
1470
- return spawn(command, args, {
1471
- detached: true,
1472
- stdio: ['ignore', logFd, logFd]
1473
- });
1474
- }
1475
-
1476
- stopHostStarter(pid) {
1477
- if (!Number.isInteger(pid) || pid <= 0) {
1478
- return;
1479
- }
1480
- try {
1481
- process.kill(-pid, 'SIGTERM');
1482
- return;
1483
- } catch {
1484
- // no-op
1485
- }
1486
- try {
1487
- process.kill(pid, 'SIGTERM');
1488
- } catch {
1489
- // no-op
1490
- }
1491
- }
1492
-
1493
- hostScenePids(sceneName) {
1494
- const cfgPath = this.sceneConfigPath(sceneName);
1495
- const pattern = isCliScene(sceneName)
1496
- ? `playwright.*launch-server.*--config ${cfgPath}`
1497
- : `playwright-mcp.*--config ${cfgPath}`;
1498
- const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
1499
-
1500
- if (cp.returncode !== 0 || !cp.stdout.trim()) {
1501
- return [];
1502
- }
1503
-
1504
- const pids = [];
1505
- for (const line of cp.stdout.split(/\r?\n/)) {
1506
- const text = line.trim();
1507
- if (/^\d+$/.test(text)) {
1508
- pids.push(Number(text));
1509
- }
1510
- }
1511
- return pids;
1512
- }
1513
-
1514
- async waitForHostPids(sceneName, fallbackPid) {
1515
- for (let i = 0; i < 5; i += 1) {
1516
- const pids = this.hostScenePids(sceneName);
1517
- if (pids.length > 0) {
1518
- return pids;
1519
- }
1520
- // eslint-disable-next-line no-await-in-loop
1521
- await sleep(100);
1522
- }
1523
- if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
1524
- return [fallbackPid];
1525
- }
1526
- return [];
1527
- }
1528
-
1529
- async startHost(sceneName, options = {}) {
1530
- try {
1531
- this.ensureCliHostHeadedCacheDir(sceneName);
1532
- this.ensureHostScenePrerequisites(sceneName);
1533
- } catch (error) {
1534
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1535
- return error.returncode || 1;
1536
- }
1537
-
1538
- fs.mkdirSync(this.config.runDir, { recursive: true });
1539
- const cfgPath = this.ensureSceneConfig(sceneName, options);
1540
- const pidFile = this.scenePidFile(sceneName);
1541
- const logFile = this.sceneLogFile(sceneName);
1542
- const port = this.scenePort(sceneName);
1543
- this.removeSceneEndpoint(sceneName);
1544
- this.removeSceneCliAttachConfig(sceneName);
1545
-
1546
- let managedPids = this.hostScenePids(sceneName);
1547
- if (managedPids.length > 0 && (await this.portReady(port))) {
1548
- this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
1549
- return 0;
1550
- }
1551
-
1552
- if (await this.portReady(port)) {
1553
- this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
1554
- this.writeStderr('Stop the conflicting process first, then retry.');
1555
- return 1;
1556
- }
1557
-
1558
- fs.rmSync(pidFile, { force: true });
1559
- const logFd = fs.openSync(logFile, 'a');
1560
- let launchCommand = null;
1561
- try {
1562
- launchCommand = this.hostLaunchCommand(sceneName, cfgPath);
1563
- } catch (error) {
1564
- fs.closeSync(logFd);
1565
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1566
- return 1;
1567
- }
1568
-
1569
- const starter = this.spawnHostProcess(launchCommand.command, launchCommand.args, logFd);
1570
- fs.closeSync(logFd);
1571
- if (typeof starter.unref === 'function') {
1572
- starter.unref();
1573
- }
1574
-
1575
- if (await this.waitForPort(port)) {
1576
- if (isCliScene(sceneName)) {
1577
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
1578
- this.writeSceneEndpoint(sceneName, {
1579
- port,
1580
- wsPath: String(cfg.wsPath || '')
1581
- });
1582
- }
1583
- managedPids = await this.waitForHostPids(sceneName, starter.pid);
1584
- if (managedPids.length > 0) {
1585
- fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
1586
- this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
1587
- this.remindCliSessionScene(sceneName);
1588
- return 0;
1589
- }
1590
- }
1591
-
1592
- this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
1593
- const tail = tailText(logFile, 30);
1594
- if (tail) {
1595
- this.writeStderr(tail);
1596
- }
1597
-
1598
- if (starter.exitCode === null && !starter.killed) {
1599
- this.stopHostStarter(starter.pid);
1600
- }
1601
-
1602
- return 1;
1603
- }
1604
-
1605
- async startDevHost(sceneName) {
1606
- this.openChromeRemoteDebuggingPage();
1607
- try {
1608
- const endpoint = this.readDevToolsActivePort();
1609
- if (!(await this.portReady(endpoint.port))) {
1610
- throw new Error(`DevToolsActivePort 指向 127.0.0.1:${endpoint.port},但该端口不可连接;请确认 Chrome remote debugging 已启用,或重新打开 Chrome 后再试。`);
1611
- }
1612
- const cfgPath = this.writeDevHostAttachConfig(sceneName, endpoint);
1613
- this.writeStdout(`[up] ${sceneName} ready via DevToolsActivePort (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
1614
- this.writeStdout(`[tip] 已尝试打开 ${this.remoteDebuggingPageUrl()},请确认 remote debugging 已启用。`);
1615
- this.writeDevHostUsage(cfgPath);
1616
- return 0;
1617
- } catch (error) {
1618
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1619
- return 1;
1620
- }
1621
- }
1622
-
1623
- async stopHost(sceneName) {
1624
- const pidFile = this.scenePidFile(sceneName);
1625
- const port = this.scenePort(sceneName);
1626
- const managedPids = this.hostScenePids(sceneName);
1627
- this.removeSceneEndpoint(sceneName);
1628
- this.removeSceneCliAttachConfig(sceneName);
1629
-
1630
- for (const pid of managedPids) {
1631
- try {
1632
- process.kill(pid, 'SIGTERM');
1633
- } catch {
1634
- // no-op
1635
- }
1636
- }
1637
-
1638
- if (managedPids.length > 0) {
1639
- await sleep(300);
1640
- }
1641
-
1642
- if (fs.existsSync(pidFile)) {
1643
- const text = fs.readFileSync(pidFile, 'utf8').trim();
1644
- if (/^\d+$/.test(text)) {
1645
- try {
1646
- process.kill(Number(text), 'SIGTERM');
1647
- } catch {
1648
- // no-op
1649
- }
1650
- }
1651
- fs.rmSync(pidFile, { force: true });
1652
- }
1653
-
1654
- if (await this.portReady(port)) {
1655
- this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
1656
- return 1;
1657
- }
1658
-
1659
- this.writeStdout(`[down] ${sceneName}`);
1660
- return 0;
1661
- }
1662
-
1663
- stopDevHost(sceneName) {
1664
- this.removeSceneEndpoint(sceneName);
1665
- this.removeSceneCliAttachConfig(sceneName);
1666
- this.removeDevHostAttachConfig(sceneName);
1667
- this.writeStdout(`[down] ${sceneName} no managed process`);
1668
- return 0;
1669
- }
1670
-
1671
- async statusHost(sceneName) {
1672
- const pidFile = this.scenePidFile(sceneName);
1673
- const port = this.scenePort(sceneName);
1674
- const managedPids = this.hostScenePids(sceneName);
1675
-
1676
- if (managedPids.length > 0 && (await this.portReady(port))) {
1677
- this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
1678
- const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
1679
- if (!pidfileValid) {
1680
- fs.mkdirSync(path.dirname(pidFile), { recursive: true });
1681
- fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
1682
- }
1683
- return 0;
1684
- }
1685
-
1686
- if (managedPids.length > 0 && !(await this.portReady(port))) {
1687
- this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
1688
- return 0;
1689
- }
1690
-
1691
- fs.rmSync(pidFile, { force: true });
1692
- if (await this.portReady(port)) {
1693
- this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
1694
- } else {
1695
- this.writeStdout(`[status] ${sceneName} stopped`);
1696
- }
1697
- return 0;
1698
- }
1699
-
1700
- statusDevHost(sceneName) {
1701
- try {
1702
- const endpoint = this.readDevToolsActivePort();
1703
- this.writeStdout(`[status] ${sceneName} configured (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
1704
- } catch {
1705
- this.writeStdout(`[status] ${sceneName} not configured`);
1706
- }
1707
- return 0;
1708
- }
1709
-
1710
- async healthScene(sceneName) {
1711
- if (isDevScene(sceneName)) {
1712
- try {
1713
- const endpoint = this.readDevToolsActivePort();
1714
- this.writeStdout(`[health] ${sceneName} ok (DevToolsActivePort 127.0.0.1:${endpoint.port})`);
1715
- return 0;
1716
- } catch (error) {
1717
- this.writeStdout(`[health] ${sceneName} fail (${error.message || String(error)})`);
1718
- return 1;
1719
- }
1720
- }
1721
-
1722
- const port = this.scenePort(sceneName);
1723
- if (await this.portReady(port)) {
1724
- this.writeStdout(`[health] ${sceneName} ok (127.0.0.1:${port})`);
1725
- return 0;
1726
- }
1727
- this.writeStdout(`[health] ${sceneName} fail (127.0.0.1:${port})`);
1728
- return 1;
1729
- }
1730
-
1731
- logsHost(sceneName) {
1732
- if (isDevScene(sceneName)) {
1733
- this.writeStdout(`[logs] ${sceneName} no managed process`);
1734
- return 0;
1735
- }
1736
-
1737
- const logFile = this.sceneLogFile(sceneName);
1738
- if (!fs.existsSync(logFile)) {
1739
- this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
1740
- return 0;
1741
- }
1742
-
1743
- const tail = tailText(logFile, 80);
1744
- if (tail) {
1745
- this.writeStdout(tail);
1746
- }
1747
- return 0;
1748
- }
1749
-
1750
- async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
1751
- const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
1752
- if (!this.ensureCommandAvailable('curl')) {
1753
- throw new Error('curl command not found');
1754
- }
1755
-
1756
- let lastError = null;
1757
- for (let i = 1; i <= retries; i += 1) {
1758
- try {
1759
- const result = this.runCmd([
1760
- 'curl',
1761
- '--fail',
1762
- '--location',
1763
- '--silent',
1764
- '--show-error',
1765
- '--connect-timeout',
1766
- String(timeoutSec),
1767
- '--max-time',
1768
- String(timeoutSec),
1769
- '--output',
1770
- output,
1771
- url
1772
- ], { captureOutput: true, check: false });
1773
- if (result.returncode !== 0) {
1774
- throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
1775
- }
1776
- return;
1777
- } catch (error) {
1778
- lastError = error;
1779
- if (i < retries) {
1780
- // eslint-disable-next-line no-await-in-loop
1781
- await sleep(1000);
1782
- }
1783
- }
1784
- }
1785
-
1786
- throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
1787
- }
1788
-
1789
- extractZipBuffer(zipBuffer, outDir) {
1790
- fs.mkdirSync(outDir, { recursive: true });
1791
- const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
1792
- fs.writeFileSync(tempZip, zipBuffer);
1793
-
1794
- const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
1795
- fs.rmSync(tempZip, { force: true });
1796
-
1797
- if (result.error) {
1798
- throw result.error;
1799
- }
1800
- if (result.status !== 0) {
1801
- throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
1802
- }
1803
- }
1804
-
1805
- extractCrx(crxFile, outDir) {
1806
- const data = fs.readFileSync(crxFile);
1807
- const offset = crxZipOffset(data);
1808
- const zipBuffer = data.subarray(offset);
1809
-
1810
- this.extractZipBuffer(zipBuffer, outDir);
1811
-
1812
- const manifest = path.join(outDir, 'manifest.json');
1813
- if (!fs.existsSync(manifest)) {
1814
- throw new Error(`${crxFile} extracted but manifest.json missing`);
1815
- }
1816
-
1817
- if (convertManifestV2ToV3(outDir)) {
1818
- this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
1819
- }
1820
- }
1821
-
1822
- async downloadExtensions(options = {}) {
1823
- if (!this.ensureCommandAvailable('unzip')) {
1824
- this.writeStderr('[ext-download] failed: unzip command not found.');
1825
- return 1;
1826
- }
1827
-
1828
- const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
1829
- const extDir = path.resolve(this.extensionDirPath());
1830
- const tmpDir = path.resolve(this.extensionTmpDirPath());
1831
-
1832
- fs.rmSync(tmpDir, { recursive: true, force: true });
1833
- fs.mkdirSync(extDir, { recursive: true });
1834
- fs.mkdirSync(tmpDir, { recursive: true });
1835
-
1836
- try {
1837
- this.writeStdout(`[info] ext dir: ${extDir}`);
1838
- this.writeStdout(`[info] tmp dir: ${tmpDir}`);
1839
-
1840
- for (const [name, extId] of EXTENSIONS) {
1841
- const url = buildCrxUrl(extId, prodversion);
1842
- const crxFile = path.join(tmpDir, `${name}.crx`);
1843
- const outDir = path.join(extDir, name);
1844
-
1845
- this.writeStdout(`[download] ${name}`);
1846
- // eslint-disable-next-line no-await-in-loop
1847
- await this.downloadFile(url, crxFile);
1848
-
1849
- this.writeStdout(`[extract] ${name}`);
1850
- fs.rmSync(outDir, { recursive: true, force: true });
1851
- this.extractCrx(crxFile, outDir);
1852
- }
1853
- } finally {
1854
- fs.rmSync(tmpDir, { recursive: true, force: true });
1855
- this.writeStdout(`[cleanup] removed ${tmpDir}`);
1856
- }
1857
-
1858
- this.writeStdout(`[done] all extensions are ready: ${extDir}`);
1859
- return 0;
1860
- }
1861
-
1862
- detectCurrentIPv4() {
1863
- const interfaces = os.networkInterfaces();
1864
- for (const values of Object.values(interfaces)) {
1865
- if (!Array.isArray(values)) {
1866
- continue;
1867
- }
1868
- for (const item of values) {
1869
- if (!item || item.internal) {
1870
- continue;
1871
- }
1872
- if (item.family === 'IPv4') {
1873
- return item.address;
1874
- }
1875
- }
1876
- }
1877
- return '';
1878
- }
1879
-
1880
- resolveMcpAddHost(hostArg) {
1881
- if (!hostArg) {
1882
- return this.config.mcpDefaultHost;
1883
- }
1884
- const value = String(hostArg).trim();
1885
- if (!value) {
1886
- return '';
1887
- }
1888
- if (value === 'current-ip') {
1889
- return this.detectCurrentIPv4();
1890
- }
1891
- return value;
1892
- }
1893
-
1894
- printMcpAdd(hostArg) {
1895
- const host = this.resolveMcpAddHost(hostArg);
1896
- if (!host) {
1897
- this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
1898
- return 1;
1899
- }
1900
-
1901
- const scenes = this.resolveTargets('all').filter(sceneName => isMcpScene(sceneName));
1902
- this.writeStdout('# 在容器中执行');
1903
- for (const sceneName of scenes) {
1904
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1905
- this.writeStdout(`claude mcp add -t http -s user playwright-${sceneName} ${url}`);
1906
- }
1907
- this.writeStdout('');
1908
- for (const sceneName of scenes) {
1909
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1910
- this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
1911
- }
1912
- this.writeStdout('');
1913
- for (const sceneName of scenes) {
1914
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1915
- this.writeStdout(`gemini mcp add -t http -s user playwright-${sceneName} ${url}`);
1916
- }
1917
-
1918
- return 0;
1919
- }
1920
-
1921
- printCliAdd() {
1922
- const lines = [
1923
- '# 在宿主机中执行',
1924
- 'PLAYWRIGHT_CLI_INSTALL_DIR="${TMPDIR:-/tmp}/manyoyo-playwright-cli-install-$$"',
1925
- 'mkdir -p "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright"',
1926
- 'cd "$PLAYWRIGHT_CLI_INSTALL_DIR"',
1927
- `npm install -g @playwright/cli@${PLAYWRIGHT_CLI_VERSION}`,
1928
- 'playwright-cli install --skills',
1929
- 'PLAYWRIGHT_CLI_SKILL_SOURCE="$PLAYWRIGHT_CLI_INSTALL_DIR/.claude/skills/playwright-cli"',
1930
- 'for target in ~/.claude/skills/playwright-cli ~/.codex/skills/playwright-cli ~/.gemini/skills/playwright-cli; do',
1931
- ' mkdir -p "$target"',
1932
- ' cp -R "$PLAYWRIGHT_CLI_SKILL_SOURCE/." "$target/"',
1933
- 'done',
1934
- 'cd "$OLDPWD"',
1935
- 'rm -rf "$PLAYWRIGHT_CLI_INSTALL_DIR"'
1936
- ];
1937
- this.writeStdout(lines.join('\n'));
1938
- return 0;
1939
- }
1940
-
1941
- printSummary() {
1942
- const scenes = this.resolveTargets('all');
1943
- this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
1944
- return 0;
1945
- }
1946
-
1947
- async runOnScene(action, sceneName, options = {}) {
1948
- const def = SCENE_DEFS[sceneName];
1949
- if (isDevScene(sceneName)) {
1950
- if (action === 'up') {
1951
- return await this.startDevHost(sceneName);
1952
- }
1953
- if (action === 'down') {
1954
- return this.stopDevHost(sceneName);
1955
- }
1956
- if (action === 'status') {
1957
- return this.statusDevHost(sceneName);
1958
- }
1959
- if (action === 'health') {
1960
- return await this.healthScene(sceneName);
1961
- }
1962
- if (action === 'logs') {
1963
- return this.logsHost(sceneName);
1964
- }
882
+ async run({ action, mode = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
883
+ if (action === 'overview') {
884
+ this.printOverview();
885
+ return await this.status();
1965
886
  }
1966
887
  if (action === 'up') {
1967
- return def.type === 'container'
1968
- ? await this.startContainer(sceneName, options)
1969
- : await this.startHost(sceneName, options);
888
+ return await this.up(mode, { extensionPaths, extensionNames });
1970
889
  }
1971
890
  if (action === 'down') {
1972
- return def.type === 'container'
1973
- ? this.stopContainer(sceneName)
1974
- : await this.stopHost(sceneName);
891
+ return await this.down();
1975
892
  }
1976
893
  if (action === 'status') {
1977
- return def.type === 'container'
1978
- ? this.statusContainer(sceneName)
1979
- : await this.statusHost(sceneName);
1980
- }
1981
- if (action === 'health') {
1982
- return await this.healthScene(sceneName);
894
+ return await this.status();
1983
895
  }
1984
896
  if (action === 'logs') {
1985
- return def.type === 'container'
1986
- ? this.logsContainer(sceneName)
1987
- : this.logsHost(sceneName);
897
+ return this.logs();
1988
898
  }
1989
- this.writeStderr(`unknown action: ${action}`);
1990
- return 1;
1991
- }
1992
-
1993
- async run({ action, scene = 'mcp-host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
1994
- if (action === 'ls') {
1995
- return this.printSummary();
1996
- }
1997
-
1998
899
  if (action === 'mcp-add') {
1999
- return this.printMcpAdd(host);
900
+ return this.mcpAdd();
2000
901
  }
2001
-
2002
- if (action === 'cli-add') {
2003
- return this.printCliAdd();
2004
- }
2005
-
2006
902
  if (action === 'ext-download') {
2007
- return await this.downloadExtensions({ prodversion });
2008
- }
2009
-
2010
- if (!VALID_ACTIONS.has(action)) {
2011
- throw new Error(`未知 plugin 动作: ${action}`);
2012
- }
2013
-
2014
- const targets = this.resolveTargets(scene);
2015
- if (targets.length === 0) {
2016
- this.writeStderr('没有可执行场景,请检查 runtime 与 enabledScenes 配置');
2017
- return 1;
2018
- }
2019
-
2020
- const resolvedExtensionPaths = action === 'up'
2021
- ? this.resolveExtensionInputs({ extensionPaths, extensionNames })
2022
- : [];
2023
-
2024
- let rc = 0;
2025
- for (const sceneName of targets) {
2026
- // eslint-disable-next-line no-await-in-loop
2027
- const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
2028
- if (code !== 0) {
2029
- rc = 1;
2030
- }
903
+ return await extensions.downloadExtensions({
904
+ pluginRoot: this.root,
905
+ prodversion: prodversion || this.config.extensionProdversion,
906
+ log: line => this.out(line),
907
+ logError: line => this.err(line)
908
+ });
2031
909
  }
2032
-
2033
- return rc;
910
+ throw new Error(`未知 playwright 动作: ${action}`);
2034
911
  }
2035
912
  }
2036
913
 
2037
914
  module.exports = {
2038
- EXTENSIONS,
2039
- SCENE_ORDER,
2040
- SCENE_DEFS,
2041
- PlaywrightPlugin
915
+ MODES,
916
+ PlaywrightPlugin,
917
+ appendNoProxy,
918
+ connectHostFor,
919
+ defaultListenHost,
920
+ headedListenHost
2042
921
  };