@xcanwin/manyoyo 7.1.5 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * manyoyo update 之后该用哪个镜像版本。
5
+ * 运行时 `imageVersion` 的优先级是 配置 > 包内默认,所以全局配置里的值会盖过新版本的默认:
6
+ * - 配置里没写:用新版本的默认;
7
+ * - 配置里的值等于“旧版本的默认”:那是 build / doctor --fix 自动写进去的,不是用户有意固定,
8
+ * 随新版本前进(调用方据此更新配置),否则用户会永远停在旧镜像;
9
+ * - 其它值:用户有意固定,尊重它,只提示。
10
+ *
11
+ * @param {{configured?: string, previousDefault?: string, nextDefault?: string}} input
12
+ * @returns {{imageVersion: string, advance: boolean, pinned: boolean}}
13
+ */
14
+ function resolveUpdateImageVersion({ configured, previousDefault, nextDefault }) {
15
+ const current = String(configured || '');
16
+ const next = String(nextDefault || '');
17
+ if (!current) {
18
+ return { imageVersion: next, advance: false, pinned: false };
19
+ }
20
+ if (current === String(previousDefault || '') && next && current !== next) {
21
+ return { imageVersion: next, advance: true, pinned: false };
22
+ }
23
+ return { imageVersion: current, advance: false, pinned: Boolean(next) && current !== next };
24
+ }
25
+
26
+ module.exports = { resolveUpdateImageVersion };
@@ -3,6 +3,73 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { writeConfigFileSecure } = require('./secure-file');
7
+
8
+ // 每个 Agent 需要哪些环境变量:CLI init 与 Web 首次向导共用的单一数据源。
9
+ // requiredAnyOf:其中任意一个非空即视为该 Agent 可用。
10
+ const AGENT_ENV_SCHEMA = {
11
+ claude: {
12
+ label: 'Claude Code',
13
+ yolo: 'c',
14
+ modelKey: 'ANTHROPIC_MODEL',
15
+ requiredAnyOf: ['ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
16
+ baseUrlKey: 'ANTHROPIC_BASE_URL',
17
+ baseUrlPresets: [{ label: 'Anthropic 官方', value: 'https://api.anthropic.com' }],
18
+ env: [
19
+ { name: 'ANTHROPIC_AUTH_TOKEN', description: 'Anthropic API Key 或兼容服务的令牌', secret: true },
20
+ { name: 'CLAUDE_CODE_OAUTH_TOKEN', description: 'Claude Code OAuth 令牌(订阅登录)', secret: true },
21
+ { name: 'ANTHROPIC_BASE_URL', description: 'API 地址(使用官方服务可留空)', secret: false },
22
+ { name: 'ANTHROPIC_MODEL', description: '默认模型', secret: false },
23
+ { name: 'ANTHROPIC_DEFAULT_OPUS_MODEL', description: 'Opus 档位对应的模型', secret: false },
24
+ { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL', description: 'Sonnet 档位对应的模型', secret: false },
25
+ { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', description: 'Haiku 档位对应的模型', secret: false },
26
+ { name: 'CLAUDE_CODE_SUBAGENT_MODEL', description: '子 Agent 使用的模型', secret: false }
27
+ ]
28
+ },
29
+ codex: {
30
+ label: 'Codex',
31
+ yolo: 'cx',
32
+ modelKey: 'OPENAI_MODEL',
33
+ requiredAnyOf: ['OPENAI_API_KEY'],
34
+ baseUrlKey: 'OPENAI_BASE_URL',
35
+ baseUrlPresets: [{ label: 'OpenAI 官方', value: 'https://api.openai.com/v1' }],
36
+ env: [
37
+ { name: 'OPENAI_API_KEY', description: 'OpenAI API Key 或兼容服务的令牌', secret: true },
38
+ { name: 'OPENAI_BASE_URL', description: 'API 地址(使用官方服务可留空)', secret: false },
39
+ { name: 'OPENAI_MODEL', description: '默认模型', secret: false }
40
+ ]
41
+ },
42
+ gemini: {
43
+ label: 'Gemini',
44
+ yolo: 'gm',
45
+ modelKey: 'GEMINI_MODEL',
46
+ requiredAnyOf: ['GEMINI_API_KEY'],
47
+ baseUrlKey: 'GOOGLE_GEMINI_BASE_URL',
48
+ baseUrlPresets: [{ label: 'Google 官方', value: 'https://generativelanguage.googleapis.com' }],
49
+ env: [
50
+ { name: 'GEMINI_API_KEY', description: 'Gemini API Key', secret: true },
51
+ { name: 'GOOGLE_GEMINI_BASE_URL', description: 'API 地址(使用官方服务可留空)', secret: false },
52
+ { name: 'GEMINI_MODEL', description: '默认模型', secret: false }
53
+ ]
54
+ },
55
+ opencode: {
56
+ label: 'OpenCode',
57
+ yolo: 'oc',
58
+ modelKey: 'OPENAI_MODEL',
59
+ requiredAnyOf: ['OPENAI_API_KEY'],
60
+ baseUrlKey: 'OPENAI_BASE_URL',
61
+ baseUrlPresets: [{ label: 'OpenAI 官方', value: 'https://api.openai.com/v1' }],
62
+ env: [
63
+ { name: 'OPENAI_API_KEY', description: 'OpenAI API Key 或兼容服务的令牌', secret: true },
64
+ { name: 'OPENAI_BASE_URL', description: 'API 地址(使用官方服务可留空)', secret: false },
65
+ { name: 'OPENAI_MODEL', description: '默认模型', secret: false }
66
+ ]
67
+ }
68
+ };
69
+
70
+ function agentEnvKeys(agent) {
71
+ return AGENT_ENV_SCHEMA[agent].env.map(item => item.name);
72
+ }
6
73
 
7
74
  function readJsonFileSafely(filePath, label, ctx) {
8
75
  if (!fs.existsSync(filePath)) return null;
@@ -138,16 +205,7 @@ function normalizeInitConfigAgents(rawAgents, ctx) {
138
205
  }
139
206
 
140
207
  function collectClaudeInitData(homeDir, ctx) {
141
- const keys = [
142
- 'ANTHROPIC_AUTH_TOKEN',
143
- 'CLAUDE_CODE_OAUTH_TOKEN',
144
- 'ANTHROPIC_BASE_URL',
145
- 'ANTHROPIC_MODEL',
146
- 'ANTHROPIC_DEFAULT_OPUS_MODEL',
147
- 'ANTHROPIC_DEFAULT_SONNET_MODEL',
148
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
149
- 'CLAUDE_CODE_SUBAGENT_MODEL'
150
- ];
208
+ const keys = agentEnvKeys('claude');
151
209
  const values = {};
152
210
  fillValuesFromEnv(keys, values);
153
211
 
@@ -160,7 +218,7 @@ function collectClaudeInitData(homeDir, ctx) {
160
218
  }
161
219
 
162
220
  function collectGeminiInitData(homeDir) {
163
- const keys = ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_API_KEY', 'GEMINI_MODEL'];
221
+ const keys = agentEnvKeys('gemini');
164
222
  const values = {};
165
223
  const notes = [];
166
224
  const volumes = [];
@@ -177,7 +235,7 @@ function collectGeminiInitData(homeDir) {
177
235
  }
178
236
 
179
237
  function collectCodexInitData(homeDir, ctx) {
180
- const keys = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'];
238
+ const keys = agentEnvKeys('codex');
181
239
  const values = {};
182
240
  const notes = [];
183
241
  const volumes = [];
@@ -206,7 +264,7 @@ function collectCodexInitData(homeDir, ctx) {
206
264
  }
207
265
 
208
266
  function collectOpenCodeInitData(homeDir, ctx) {
209
- const keys = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'];
267
+ const keys = agentEnvKeys('opencode');
210
268
  const values = {};
211
269
  const notes = [];
212
270
  const volumes = [];
@@ -253,19 +311,19 @@ function collectOpenCodeInitData(homeDir, ctx) {
253
311
 
254
312
  const AGENT_INIT_SPECS = {
255
313
  claude: {
256
- yolo: 'c',
314
+ yolo: AGENT_ENV_SCHEMA.claude.yolo,
257
315
  collect: (homeDir, ctx) => collectClaudeInitData(homeDir, ctx)
258
316
  },
259
317
  codex: {
260
- yolo: 'cx',
318
+ yolo: AGENT_ENV_SCHEMA.codex.yolo,
261
319
  collect: (homeDir, ctx) => collectCodexInitData(homeDir, ctx)
262
320
  },
263
321
  gemini: {
264
- yolo: 'gm',
322
+ yolo: AGENT_ENV_SCHEMA.gemini.yolo,
265
323
  collect: (homeDir) => collectGeminiInitData(homeDir)
266
324
  },
267
325
  opencode: {
268
- yolo: 'oc',
326
+ yolo: AGENT_ENV_SCHEMA.opencode.yolo,
269
327
  collect: (homeDir, ctx) => collectOpenCodeInitData(homeDir, ctx)
270
328
  }
271
329
  };
@@ -393,10 +451,12 @@ async function initAgentConfigs(rawAgents, options = {}) {
393
451
 
394
452
  if (hasConfigChanged || !fs.existsSync(manyoyoConfigPath)) {
395
453
  manyoyoConfig.runs = runsMap;
396
- fs.writeFileSync(manyoyoConfigPath, `${JSON.stringify(manyoyoConfig, null, 4)}\n`);
454
+ writeConfigFileSecure(manyoyoConfigPath, `${JSON.stringify(manyoyoConfig, null, 4)}\n`);
397
455
  }
398
456
  }
399
457
 
400
458
  module.exports = {
401
- initAgentConfigs
459
+ initAgentConfigs,
460
+ AGENT_ENV_SCHEMA,
461
+ agentEnvKeys
402
462
  };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ const LOGIN_TOKEN_TTL_MS = 60 * 1000;
9
+ const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
10
+
11
+ function getLoginTokenDir(homeDir = os.homedir()) {
12
+ return path.join(homeDir, '.manyoyo', 'serve', 'login-tokens');
13
+ }
14
+
15
+ function hashToken(token) {
16
+ return crypto.createHash('sha256').update(token).digest('hex');
17
+ }
18
+
19
+ function isLoopbackHost(host) {
20
+ const text = String(host || '').trim().toLowerCase();
21
+ return text === '127.0.0.1' || text === '::1' || text === '[::1]' || text === 'localhost';
22
+ }
23
+
24
+ // 删除过期(或无法读取)的令牌文件;目录不存在视为无事可做
25
+ function pruneLoginTokens(dir, options = {}) {
26
+ const now = options.now || Date.now();
27
+ const ttlMs = options.ttlMs || LOGIN_TOKEN_TTL_MS;
28
+ let names;
29
+ try {
30
+ names = fs.readdirSync(dir);
31
+ } catch (e) {
32
+ return;
33
+ }
34
+ for (const name of names) {
35
+ const filePath = path.join(dir, name);
36
+ try {
37
+ if (now - fs.lstatSync(filePath).mtimeMs > ttlMs) {
38
+ fs.unlinkSync(filePath);
39
+ }
40
+ } catch (e) {
41
+ // 被并发消费/清理,忽略
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 启动器侧:生成一次性令牌,只把 sha256(令牌) 落盘成空文件。
48
+ * 目录 0700、文件 0600;顺带清理过期文件。
49
+ */
50
+ function issueLoginToken(dir, options = {}) {
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ fs.chmodSync(dir, 0o700);
53
+ pruneLoginTokens(dir, options);
54
+ const token = crypto.randomBytes(32).toString('hex');
55
+ fs.writeFileSync(path.join(dir, hashToken(token)), '', { mode: 0o600, flag: 'wx' });
56
+ return token;
57
+ }
58
+
59
+ /**
60
+ * serve 侧:令牌有效则消费(删除文件)并返回 true。
61
+ * 格式不符、文件不存在、不是普通文件、已过期、被并发消费,都返回 false;过期文件会被顺手删掉。
62
+ */
63
+ function consumeLoginToken(dir, token, options = {}) {
64
+ if (!dir || typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
65
+ return false;
66
+ }
67
+ const now = options.now || Date.now();
68
+ const ttlMs = options.ttlMs || LOGIN_TOKEN_TTL_MS;
69
+ const filePath = path.join(dir, hashToken(token));
70
+ let stat;
71
+ try {
72
+ stat = fs.lstatSync(filePath);
73
+ } catch (e) {
74
+ return false;
75
+ }
76
+ const fresh = stat.isFile() && now - stat.mtimeMs <= ttlMs;
77
+ try {
78
+ fs.unlinkSync(filePath);
79
+ } catch (e) {
80
+ return false;
81
+ }
82
+ return fresh;
83
+ }
84
+
85
+ module.exports = {
86
+ LOGIN_TOKEN_TTL_MS,
87
+ getLoginTokenDir,
88
+ isLoopbackHost,
89
+ pruneLoginTokens,
90
+ issueLoginToken,
91
+ consumeLoginToken
92
+ };
package/lib/mirrors.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const { normalizeMirrors } = require('./runtime-normalizers');
4
+
5
+ function hasEnvKey(envArgs, key) {
6
+ const list = Array.isArray(envArgs) ? envArgs : [];
7
+ for (let i = 0; i < list.length; i += 1) {
8
+ if (list[i] !== '--env' && list[i] !== '-e') continue;
9
+ const text = String(list[i + 1] || '');
10
+ const idx = text.indexOf('=');
11
+ if ((idx > 0 ? text.slice(0, idx) : text) === key) return true;
12
+ }
13
+ return false;
14
+ }
15
+
16
+ /**
17
+ * npm / pip 源通过容器环境变量生效(镜像本身不变)。用户在 env 里自己设了同名变量时以用户为准。
18
+ * @param {{apt?: string, npm?: string, pip?: string}} mirrors 已归一化的 mirrors
19
+ * @param {string[]} existingEnvArgs 已有的 --env 参数
20
+ * @returns {string[]} 追加的 --env 参数
21
+ */
22
+ function buildMirrorEnvArgs(mirrors, existingEnvArgs = []) {
23
+ const source = mirrors && typeof mirrors === 'object' ? mirrors : {};
24
+ const pairs = [];
25
+ if (source.npm) pairs.push(['NPM_CONFIG_REGISTRY', source.npm]);
26
+ if (source.pip) {
27
+ pairs.push(['PIP_INDEX_URL', source.pip]);
28
+ if (/^http:\/\//i.test(source.pip)) {
29
+ pairs.push(['PIP_TRUSTED_HOST', new URL(source.pip).hostname]);
30
+ }
31
+ }
32
+ const args = [];
33
+ for (const [key, value] of pairs) {
34
+ if (!hasEnvKey(existingEnvArgs, key)) args.push('--env', `${key}=${value}`);
35
+ }
36
+ return args;
37
+ }
38
+
39
+ // 固定脚本:镜像地址只从环境变量读取,不拼接进脚本。
40
+ // 第一次执行时备份原文件(.manyoyo-orig,apt 不读这个后缀),之后每次都从备份还原再替换,所以重复执行、换源都幂等。
41
+ // 只替换主机部分,保留 /ubuntu、/ubuntu-ports 路径(amd64 与 arm64 通用)。
42
+ const APT_MIRROR_SCRIPT = [
43
+ '[ -n "$MANYOYO_APT_MIRROR" ] || exit 0',
44
+ 'for f in /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list; do',
45
+ ' [ -f "$f" ] || continue',
46
+ ' [ -f "$f.manyoyo-orig" ] || cp -p "$f" "$f.manyoyo-orig" || exit 1',
47
+ ' cp -p "$f.manyoyo-orig" "$f" || exit 1',
48
+ ' sed -i -E "s|https?://[^/ ]*\\.ubuntu\\.com|${MANYOYO_APT_MIRROR%/}|g" "$f" || exit 1',
49
+ 'done'
50
+ ].join('\n');
51
+
52
+ function buildAptMirrorExecArgs(containerName, aptMirror) {
53
+ return ['exec', '--user', 'root', '--env', `MANYOYO_APT_MIRROR=${aptMirror}`, containerName, '/bin/sh', '-c', APT_MIRROR_SCRIPT];
54
+ }
55
+
56
+ /**
57
+ * 容器创建后让 apt 源生效;失败只警告,不阻断创建。
58
+ */
59
+ function applyAptMirror({ dockerExecArgs, containerName, mirrors, warn = () => {} }) {
60
+ const apt = mirrors && typeof mirrors.apt === 'string' ? mirrors.apt : '';
61
+ if (!apt) return false;
62
+ try {
63
+ dockerExecArgs(buildAptMirrorExecArgs(containerName, apt), { stdio: 'pipe' });
64
+ return true;
65
+ } catch (error) {
66
+ warn(`apt 软件源配置失败(不影响使用,容器内仍是默认源): ${String(error && error.message || error).split('\n')[0]}`);
67
+ return false;
68
+ }
69
+ }
70
+
71
+ module.exports = { normalizeMirrors, buildMirrorEnvArgs, buildAptMirrorExecArgs, applyAptMirror, APT_MIRROR_SCRIPT };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ // 离线安装器在后台导入镜像时会写 ~/.manyoyo/runtime/import/loading.json(含 pid)。
4
+ // serve / CLI 看到它就等待,不要同时去 ghcr 拉同一个镜像;导入失败或进程消失则视为没有进行中的导入。
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+
11
+ function getImportMarkerPath(homeDir = os.homedir()) {
12
+ return path.join(homeDir, '.manyoyo', 'runtime', 'import', 'loading.json');
13
+ }
14
+
15
+ function isAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch (error) {
20
+ return error && error.code === 'EPERM';
21
+ }
22
+ }
23
+
24
+ // pid 可能在导入器被强杀后被无关进程复用:必须确认命令行里是后台导入脚本 finish.sh
25
+ function isImportProcess(pid) {
26
+ try {
27
+ const command = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' }).stdout || '';
28
+ return /finish\.sh/.test(command);
29
+ } catch (error) {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function readImportState(homeDir = os.homedir(), options = {}) {
35
+ const isImporter = options.isImporter || isImportProcess;
36
+ try {
37
+ const marker = JSON.parse(fs.readFileSync(getImportMarkerPath(homeDir), 'utf-8'));
38
+ if (Number.isInteger(marker.pid) && marker.pid > 0 && isAlive(marker.pid) && isImporter(marker.pid)) {
39
+ return { active: true, message: String(marker.message || '正在导入离线镜像') };
40
+ }
41
+ } catch (error) {
42
+ // 没有标记文件就是没有导入
43
+ }
44
+ return { active: false, message: '' };
45
+ }
46
+
47
+ async function waitForImport({ homeDir, readState, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), onWait = () => {}, timeoutMs = 10 * 60 * 1000, pollMs = 1000 } = {}) {
48
+ let waited = 0;
49
+ const read = readState || (() => readImportState(homeDir));
50
+ let state = read();
51
+ if (state.active) onWait(state);
52
+ while (state.active && waited < timeoutMs) {
53
+ await sleep(pollMs);
54
+ waited += pollMs;
55
+ state = read();
56
+ }
57
+ return !state.active;
58
+ }
59
+
60
+ module.exports = { getImportMarkerPath, readImportState, waitForImport, isImportProcess };
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const crypto = require('crypto');
8
8
  const { spawn, spawnSync } = require('child_process');
9
9
  const { playwrightCliVersion: PLAYWRIGHT_CLI_VERSION } = require('../../package.json');
10
+ const { resolveForcedRuntime } = require('../container-runtime');
10
11
 
11
12
  const EXTENSIONS = [
12
13
  ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
@@ -314,6 +315,7 @@ class PlaywrightPlugin {
314
315
  this.runConfig = asObject(options.runConfig);
315
316
  this.rootGlobalConfig = asObject(options.rootGlobalConfig);
316
317
  this.rootRunConfig = asObject(options.rootRunConfig);
318
+ this.runtimeEnv = {};
317
319
  this.config = this.resolveConfig();
318
320
  }
319
321
 
@@ -398,6 +400,12 @@ class PlaywrightPlugin {
398
400
  return configured;
399
401
  }
400
402
 
403
+ const forced = resolveForcedRuntime({ configured: this.rootGlobalConfig.containerRuntime });
404
+ if (forced) {
405
+ this.runtimeEnv = forced.env;
406
+ return forced.command;
407
+ }
408
+
401
409
  const candidates = ['docker', 'podman'];
402
410
  for (const cmd of candidates) {
403
411
  if (this.ensureCommandAvailable(cmd)) {
@@ -498,9 +506,10 @@ class PlaywrightPlugin {
498
506
  }
499
507
 
500
508
  runCmd(args, { env = null, captureOutput = false, check = true } = {}) {
509
+ const isRuntimeCommand = args[0] === this.config.containerRuntime;
501
510
  const result = spawnSync(args[0], args.slice(1), {
502
511
  encoding: 'utf8',
503
- env: env || process.env,
512
+ env: isRuntimeCommand ? { ...(env || process.env), ...this.runtimeEnv } : (env || process.env),
504
513
  stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
505
514
  });
506
515
 
@@ -531,6 +540,9 @@ class PlaywrightPlugin {
531
540
 
532
541
  ensureCommandAvailable(command) {
533
542
  const name = String(command || '').trim();
543
+ if (path.isAbsolute(name)) {
544
+ return fs.existsSync(name);
545
+ }
534
546
  if (!/^[A-Za-z0-9._-]+$/.test(name)) {
535
547
  return false;
536
548
  }
@@ -1240,7 +1252,7 @@ class PlaywrightPlugin {
1240
1252
  return { envEntries: [], extraArgs: [], volumeEntries: [] };
1241
1253
  }
1242
1254
 
1243
- const normalizedDockerCmd = String(dockerCmd || '').trim().toLowerCase();
1255
+ const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1244
1256
  const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1245
1257
  const remoteEndpoint = `ws://${connectHost}:${endpoint.port}${endpoint.wsPath}`;
1246
1258
  const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
@@ -1268,7 +1280,7 @@ class PlaywrightPlugin {
1268
1280
 
1269
1281
  buildDevHostCliSessionIntegration(dockerCmd, sceneName) {
1270
1282
  const endpoint = this.readDevToolsActivePort();
1271
- const normalizedDockerCmd = String(dockerCmd || '').trim().toLowerCase();
1283
+ const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1272
1284
  const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1273
1285
  const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
1274
1286
  const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const { getPrivatePodmanPaths } = require('./container-runtime');
5
+
6
+ function shQuote(value) {
7
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
8
+ }
9
+
10
+ function privateEnv(paths) {
11
+ return {
12
+ XDG_CONFIG_HOME: paths.configHome,
13
+ XDG_DATA_HOME: paths.dataHome,
14
+ CONTAINERS_CONF: paths.containersConf
15
+ };
16
+ }
17
+
18
+ /**
19
+ * 输出可以 eval 的 shell 代码:只在当前终端定义一个 podman 函数(带上私有 Podman 的绝对路径与环境变量),
20
+ * 不改全局 PATH / XDG_*,不影响用户自己的其它工具。
21
+ */
22
+ function buildShellFunction(paths, shell = 'sh') {
23
+ const env = privateEnv(paths);
24
+ if (shell === 'fish') {
25
+ const assigns = Object.entries(env).map(([key, value]) => `${key}=${shQuote(value)}`).join(' ');
26
+ return `function podman\n env ${assigns} ${shQuote(paths.bin)} $argv\nend\n`;
27
+ }
28
+ const assigns = Object.entries(env).map(([key, value]) => `${key}=${shQuote(value)}`).join(' ');
29
+ return `podman() { env ${assigns} ${shQuote(paths.bin)} "$@"; }\n`;
30
+ }
31
+
32
+ function usageText(commandName) {
33
+ return [
34
+ `用法:`,
35
+ ` ${commandName} podman <podman 参数...> 用 ${commandName} 的私有 Podman 执行一次,参数原样传给 podman`,
36
+ ` eval "$(${commandName} podman env)" 在当前终端定义 podman 函数,之后直接输入 podman ps -a`,
37
+ ` ${commandName} podman env --shell fish fish 终端使用这个形式`,
38
+ `说明: 函数只在当前终端有效,关闭终端即失效;不修改 PATH,也不影响你自己安装的 Podman。`
39
+ ].join('\n');
40
+ }
41
+
42
+ /**
43
+ * @returns {number} 退出码
44
+ */
45
+ function runPodmanCommand(args, options = {}) {
46
+ const log = options.log || (line => console.log(line));
47
+ const errLog = options.errLog || (line => console.error(line));
48
+ const commandName = options.commandName || 'manyoyo';
49
+ const paths = getPrivatePodmanPaths(options.homeDir);
50
+ const exists = options.exists || (file => require('fs').existsSync(file));
51
+ if (!exists(paths.bin)) {
52
+ errLog(`没有找到 ${commandName} 的私有 Podman(${paths.bin})。当前没有使用私有 Podman:离线完整包安装后才有;使用你自己的 docker / podman 时直接用它们即可。`);
53
+ return 1;
54
+ }
55
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
56
+ log(usageText(commandName));
57
+ return 0;
58
+ }
59
+ if (args[0] === 'env') {
60
+ const rest = args.slice(1);
61
+ let shell = '';
62
+ for (let i = 0; i < rest.length; i += 1) {
63
+ if (rest[i] === '--shell') { shell = rest[i + 1] || ''; i += 1; }
64
+ else { errLog(`未知参数: ${rest[i]}`); return 2; }
65
+ }
66
+ if (!shell) shell = require('path').basename(String(options.shell || process.env.SHELL || 'sh'));
67
+ log(buildShellFunction(paths, shell === 'fish' ? 'fish' : 'sh').trimEnd());
68
+ return 0;
69
+ }
70
+ const run = options.spawnSync || spawnSync;
71
+ const result = run(paths.bin, args, { stdio: 'inherit', env: { ...process.env, ...privateEnv(paths) } });
72
+ if (result.error) {
73
+ errLog(`无法执行私有 Podman: ${result.error.message}`);
74
+ return 1;
75
+ }
76
+ if (result.signal) return 1;
77
+ return result.status === null || result.status === undefined ? 1 : result.status;
78
+ }
79
+
80
+ module.exports = { runPodmanCommand, buildShellFunction, usageText };