@xcanwin/manyoyo 7.1.6 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -169
- package/bin/manyoyo.js +604 -231
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +324 -0
- package/lib/container-run.js +3 -0
- package/lib/container-runtime.js +121 -0
- package/lib/doctor.js +77 -14
- package/lib/download-verified.js +48 -0
- package/lib/error-hints.js +96 -0
- package/lib/global-config.js +4 -4
- package/lib/headless.js +53 -0
- package/lib/image-build.js +11 -9
- package/lib/image-prune.js +44 -0
- package/lib/image-pull.js +76 -0
- package/lib/image-version-policy.js +26 -0
- package/lib/init-config.js +79 -19
- package/lib/login-token.js +92 -0
- package/lib/mirrors.js +71 -0
- package/lib/offline-import.js +60 -0
- package/lib/plugin/playwright.js +15 -3
- package/lib/podman-passthrough.js +80 -0
- package/lib/proxy-config.js +138 -0
- package/lib/runtime-heal.js +175 -0
- package/lib/runtime-normalizers.js +50 -0
- package/lib/secure-file.js +38 -0
- package/lib/setup-cli.js +215 -0
- package/lib/setup-config.js +32 -0
- package/lib/setup.js +188 -0
- package/lib/uninstall.js +296 -0
- package/lib/update-check.js +91 -0
- package/lib/web/AGENTS.md +4 -6
- package/lib/web/index.html +213 -0
- package/lib/web/server.js +653 -51
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +14 -3
- package/package.json +14 -10
- package/lib/web/frontend/shadcn.html +0 -213
|
@@ -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 };
|
package/lib/init-config.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
314
|
+
yolo: AGENT_ENV_SCHEMA.claude.yolo,
|
|
257
315
|
collect: (homeDir, ctx) => collectClaudeInitData(homeDir, ctx)
|
|
258
316
|
},
|
|
259
317
|
codex: {
|
|
260
|
-
yolo:
|
|
318
|
+
yolo: AGENT_ENV_SCHEMA.codex.yolo,
|
|
261
319
|
collect: (homeDir, ctx) => collectCodexInitData(homeDir, ctx)
|
|
262
320
|
},
|
|
263
321
|
gemini: {
|
|
264
|
-
yolo:
|
|
322
|
+
yolo: AGENT_ENV_SCHEMA.gemini.yolo,
|
|
265
323
|
collect: (homeDir) => collectGeminiInitData(homeDir)
|
|
266
324
|
},
|
|
267
325
|
opencode: {
|
|
268
|
-
yolo:
|
|
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
|
-
|
|
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 };
|
package/lib/plugin/playwright.js
CHANGED
|
@@ -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 (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
52
|
+
log(usageText(commandName));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (!exists(paths.bin)) {
|
|
56
|
+
errLog(`没有找到 ${commandName} 的私有 Podman(${paths.bin})。当前没有使用私有 Podman:离线完整包安装后才有;使用你自己的 docker / podman 时直接用它们即可。`);
|
|
57
|
+
return 1;
|
|
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 };
|