@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.
- package/README.md +24 -14
- package/bin/manyoyo.js +490 -83
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +325 -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/frontend/shadcn.html +86 -86
- package/lib/web/server.js +641 -14
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +13 -2
- package/package.json +9 -9
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const INFO_TIMEOUT_MS = 3000;
|
|
9
|
+
const VERSION_TIMEOUT_MS = 5000;
|
|
10
|
+
const SUPPORTED_RUNTIMES = ['docker', 'podman'];
|
|
11
|
+
|
|
12
|
+
function getPrivatePodmanPaths(homeDir = os.homedir()) {
|
|
13
|
+
const root = path.join(homeDir, '.manyoyo', 'runtime', 'podman');
|
|
14
|
+
return {
|
|
15
|
+
root,
|
|
16
|
+
bin: path.join(root, 'bin', 'podman'),
|
|
17
|
+
configHome: path.join(root, 'config'),
|
|
18
|
+
dataHome: path.join(root, 'data'),
|
|
19
|
+
containersConf: path.join(root, 'containers.conf')
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensurePrivatePodmanConfig(paths) {
|
|
24
|
+
fs.mkdirSync(paths.configHome, { recursive: true });
|
|
25
|
+
fs.mkdirSync(paths.dataHome, { recursive: true });
|
|
26
|
+
if (!fs.existsSync(paths.containersConf)) {
|
|
27
|
+
const helperDir = JSON.stringify(path.dirname(paths.bin));
|
|
28
|
+
fs.writeFileSync(paths.containersConf, `[engine]\nhelper_binaries_dir = [${helperDir}]\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeConfigured(configured) {
|
|
33
|
+
const value = String(configured || '').trim().toLowerCase();
|
|
34
|
+
if (!value || value === 'auto') return '';
|
|
35
|
+
if (!SUPPORTED_RUNTIMES.includes(value)) {
|
|
36
|
+
throw new Error(`containerRuntime 无效: ${configured}(可选 auto / docker / podman)`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 不启动任何子进程:用户显式配置 > 私有 Podman;都没有返回 null。
|
|
42
|
+
function resolveForcedRuntime(options = {}) {
|
|
43
|
+
const configured = normalizeConfigured(options.configured);
|
|
44
|
+
if (configured) {
|
|
45
|
+
return { command: configured, env: {}, source: 'config' };
|
|
46
|
+
}
|
|
47
|
+
const paths = getPrivatePodmanPaths(options.homeDir);
|
|
48
|
+
if (fs.existsSync(paths.bin)) {
|
|
49
|
+
ensurePrivatePodmanConfig(paths);
|
|
50
|
+
return {
|
|
51
|
+
command: paths.bin,
|
|
52
|
+
env: {
|
|
53
|
+
XDG_CONFIG_HOME: paths.configHome,
|
|
54
|
+
XDG_DATA_HOME: paths.dataHome,
|
|
55
|
+
CONTAINERS_CONF: paths.containersConf
|
|
56
|
+
},
|
|
57
|
+
source: 'private-podman'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultRun(command, args, options = {}) {
|
|
64
|
+
const result = spawnSync(command, args, {
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
stdio: 'pipe',
|
|
67
|
+
timeout: options.timeout,
|
|
68
|
+
env: options.env
|
|
69
|
+
});
|
|
70
|
+
if (result.error) throw result.error;
|
|
71
|
+
if (result.status !== 0) {
|
|
72
|
+
throw new Error(`${command} ${args.join(' ')} 退出码 ${result.status}`);
|
|
73
|
+
}
|
|
74
|
+
return result.stdout || '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function succeeds(run, command, args, timeout) {
|
|
78
|
+
try {
|
|
79
|
+
run(command, args, { timeout });
|
|
80
|
+
return true;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 选择容器运行时。
|
|
88
|
+
* 优先级:containerRuntime 配置 > 私有 Podman > daemon 可用的 docker > daemon 可用的 podman
|
|
89
|
+
* > 仅 --version 可用的第一个 > 抛错。
|
|
90
|
+
* @returns {{command: string, env: Object<string,string>, source: string}}
|
|
91
|
+
*/
|
|
92
|
+
function selectContainerRuntime(options = {}) {
|
|
93
|
+
const forced = resolveForcedRuntime(options);
|
|
94
|
+
if (forced) return forced;
|
|
95
|
+
|
|
96
|
+
const run = typeof options.run === 'function' ? options.run : defaultRun;
|
|
97
|
+
for (const name of SUPPORTED_RUNTIMES) {
|
|
98
|
+
if (succeeds(run, name, ['info'], INFO_TIMEOUT_MS)) {
|
|
99
|
+
return { command: name, env: {}, source: `${name}-daemon` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const name of SUPPORTED_RUNTIMES) {
|
|
103
|
+
if (succeeds(run, name, ['--version'], VERSION_TIMEOUT_MS)) {
|
|
104
|
+
return { command: name, env: {}, source: 'version-only' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new Error('未找到可用的容器运行时 (docker/podman not found):请安装并启动 Docker Desktop 或 Podman。');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 只给运行时子进程用:env 为空时返回 undefined(子进程照常继承 process.env)。
|
|
111
|
+
function mergeRuntimeEnv(env, base = process.env) {
|
|
112
|
+
if (!env || Object.keys(env).length === 0) return undefined;
|
|
113
|
+
return { ...base, ...env };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
selectContainerRuntime,
|
|
118
|
+
resolveForcedRuntime,
|
|
119
|
+
getPrivatePodmanPaths,
|
|
120
|
+
mergeRuntimeEnv
|
|
121
|
+
};
|
package/lib/doctor.js
CHANGED
|
@@ -7,35 +7,47 @@ function createCheck(code, status, summary, action = '', detail = '') {
|
|
|
7
7
|
return { code, status, summary, action, detail };
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// 卡死的 daemon(如无响应的 Docker Desktop)不能让诊断无限挂起
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 10000;
|
|
12
|
+
|
|
13
|
+
// runCommand 可以是同步或异步执行器;serve 里传异步的,避免阻塞事件循环
|
|
14
|
+
async function runDoctorChecks(options = {}) {
|
|
11
15
|
const checks = [];
|
|
12
16
|
const runCommand = typeof options.runCommand === 'function' ? options.runCommand : () => {
|
|
13
17
|
throw new Error('未配置命令执行器');
|
|
14
18
|
};
|
|
19
|
+
const selectRuntime = typeof options.selectRuntime === 'function' ? options.selectRuntime : () => {
|
|
20
|
+
throw new Error('未配置运行时选择器');
|
|
21
|
+
};
|
|
15
22
|
let runtimeCommand = '';
|
|
23
|
+
let runtimeSource = null;
|
|
24
|
+
let runtimeEnv = {};
|
|
16
25
|
|
|
17
|
-
|
|
26
|
+
try {
|
|
27
|
+
const selection = selectRuntime();
|
|
28
|
+
runtimeCommand = selection.command;
|
|
29
|
+
runtimeSource = selection.source;
|
|
30
|
+
runtimeEnv = selection.env || {};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
checks.push(createCheck('RUNTIME_UNAVAILABLE', 'error', '未找到 Docker 或 Podman', '安装并启动 Docker Desktop 或 Podman。', error.message || ''));
|
|
33
|
+
}
|
|
34
|
+
if (runtimeCommand) {
|
|
35
|
+
let versionDetail = '';
|
|
18
36
|
try {
|
|
19
|
-
|
|
20
|
-
runtimeCommand = candidate;
|
|
21
|
-
checks.push(createCheck('RUNTIME_AVAILABLE', 'ok', `检测到 ${candidate}`, '', detail));
|
|
22
|
-
break;
|
|
37
|
+
versionDetail = String(await runCommand(runtimeCommand, ['--version'], { env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS }) || '').trim();
|
|
23
38
|
} catch (error) {
|
|
24
|
-
//
|
|
39
|
+
// 版本信息只是补充,daemon 检查会给出真正的结论。
|
|
25
40
|
}
|
|
26
|
-
|
|
27
|
-
if (!runtimeCommand) {
|
|
28
|
-
checks.push(createCheck('RUNTIME_UNAVAILABLE', 'error', '未找到 Docker 或 Podman', '安装并启动 Docker Desktop 或 Podman。'));
|
|
29
|
-
} else {
|
|
41
|
+
checks.push(createCheck('RUNTIME_AVAILABLE', 'ok', `容器运行时: ${runtimeCommand}(来源: ${runtimeSource})`, '', versionDetail));
|
|
30
42
|
try {
|
|
31
|
-
const detail = String(runCommand(runtimeCommand, ['info']) || '').trim();
|
|
43
|
+
const detail = String(await runCommand(runtimeCommand, ['info'], { env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS }) || '').trim();
|
|
32
44
|
checks.push(createCheck('DAEMON_AVAILABLE', 'ok', `${runtimeCommand} daemon 可用`, '', detail));
|
|
33
45
|
} catch (error) {
|
|
34
46
|
checks.push(createCheck('DAEMON_UNAVAILABLE', 'error', `${runtimeCommand} daemon 不可用`, `启动 ${runtimeCommand} daemon 后重试。`, error.message || ''));
|
|
35
47
|
}
|
|
36
48
|
try {
|
|
37
49
|
const image = `${options.imageName || ''}:${options.imageVersion || ''}`;
|
|
38
|
-
const detail = String(runCommand(runtimeCommand, ['image', 'inspect', image]) || '').trim();
|
|
50
|
+
const detail = String(await runCommand(runtimeCommand, ['image', 'inspect', image], { env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS }) || '').trim();
|
|
39
51
|
checks.push(createCheck('IMAGE_AVAILABLE', 'ok', `镜像可用: ${image}`, '', detail));
|
|
40
52
|
} catch (error) {
|
|
41
53
|
checks.push(createCheck('IMAGE_MISSING', 'warning', '目标镜像尚不可用', '执行 manyoyo build,或拉取匹配镜像。', error.message || ''));
|
|
@@ -74,11 +86,62 @@ function runDoctorChecks(options = {}) {
|
|
|
74
86
|
return {
|
|
75
87
|
version: 1,
|
|
76
88
|
runtimeCommand: runtimeCommand || null,
|
|
89
|
+
runtimeSource,
|
|
77
90
|
ok: !checks.some(check => check.status === 'error'),
|
|
78
91
|
checks
|
|
79
92
|
};
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
async function attemptFix(handler) {
|
|
96
|
+
if (typeof handler !== 'function') {
|
|
97
|
+
return { attempted: false, fixed: false, message: '' };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = await handler();
|
|
101
|
+
return { attempted: true, fixed: Boolean(result && result.fixed), message: String(result && result.message || '') };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { attempted: true, fixed: false, message: String(error && error.message || error).split('\n')[0] };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 对 runDoctorChecks 的报告执行修复:每个未通过的检查项都带 fix: {attempted, fixed, message}。
|
|
109
|
+
* handlers: startRuntime / pullImage / createConfig / suggestPort,各返回 {fixed, message}。
|
|
110
|
+
*/
|
|
111
|
+
async function applyDoctorFixes(report, handlers = {}) {
|
|
112
|
+
const checks = report.checks.map(check => ({ ...check }));
|
|
113
|
+
const find = code => checks.find(check => check.code === code);
|
|
114
|
+
|
|
115
|
+
const daemon = find('DAEMON_UNAVAILABLE');
|
|
116
|
+
if (daemon) daemon.fix = await attemptFix(handlers.startRuntime);
|
|
117
|
+
|
|
118
|
+
const image = find('IMAGE_MISSING');
|
|
119
|
+
if (image) {
|
|
120
|
+
image.fix = daemon && !daemon.fix.fixed
|
|
121
|
+
? { attempted: false, fixed: false, message: '需先修复容器运行时' }
|
|
122
|
+
: await attemptFix(handlers.pullImage);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const config = find('CONFIG_MISSING');
|
|
126
|
+
if (config) config.fix = await attemptFix(handlers.createConfig);
|
|
127
|
+
|
|
128
|
+
const port = find('PORT_OCCUPIED');
|
|
129
|
+
if (port) port.fix = await attemptFix(handlers.suggestPort);
|
|
130
|
+
|
|
131
|
+
checks.forEach(check => {
|
|
132
|
+
if (check.status !== 'ok' && !check.fix) {
|
|
133
|
+
check.fix = { attempted: false, fixed: false, message: '' };
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
...report,
|
|
139
|
+
ok: !checks.some(check => check.status === 'error' && !(check.fix && check.fix.fixed)),
|
|
140
|
+
checks
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
82
144
|
module.exports = {
|
|
83
|
-
runDoctorChecks
|
|
145
|
+
runDoctorChecks,
|
|
146
|
+
applyDoctorFixes
|
|
84
147
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { pipeline } = require('stream/promises');
|
|
7
|
+
const { Readable } = require('stream');
|
|
8
|
+
|
|
9
|
+
function sha256File(filePath) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const hash = crypto.createHash('sha256');
|
|
12
|
+
fs.createReadStream(filePath)
|
|
13
|
+
.on('data', chunk => hash.update(chunk))
|
|
14
|
+
.on('error', reject)
|
|
15
|
+
.on('end', () => resolve(hash.digest('hex')));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 下载并校验 SHA256。缓存命中(文件存在且哈希一致)直接复用;
|
|
21
|
+
* 哈希不符一律删除并报错,绝不保留不可信文件。
|
|
22
|
+
*/
|
|
23
|
+
async function downloadVerified({ url, sha256, dest, fetchImpl = fetch, log = () => {} }) {
|
|
24
|
+
if (!/^[0-9a-f]{64}$/.test(sha256 || '')) {
|
|
25
|
+
throw new Error(`缺少有效的 SHA256: ${url}`);
|
|
26
|
+
}
|
|
27
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
28
|
+
if (fs.existsSync(dest) && await sha256File(dest) === sha256) {
|
|
29
|
+
log(`缓存命中: ${path.basename(dest)}`);
|
|
30
|
+
return dest;
|
|
31
|
+
}
|
|
32
|
+
log(`下载: ${url}`);
|
|
33
|
+
const response = await fetchImpl(url, { redirect: 'follow' });
|
|
34
|
+
if (!response.ok || !response.body) {
|
|
35
|
+
throw new Error(`下载失败 (${response.status}): ${url}`);
|
|
36
|
+
}
|
|
37
|
+
const partial = `${dest}.partial`;
|
|
38
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial));
|
|
39
|
+
const actual = await sha256File(partial);
|
|
40
|
+
if (actual !== sha256) {
|
|
41
|
+
fs.rmSync(partial, { force: true });
|
|
42
|
+
throw new Error(`SHA256 校验失败: ${path.basename(dest)}(期望 ${sha256},实际 ${actual})`);
|
|
43
|
+
}
|
|
44
|
+
fs.renameSync(partial, dest);
|
|
45
|
+
return dest;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { sha256File, downloadVerified };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 原始运行时/系统错误 -> “原因 + 下一步”。新增映射只改这里。
|
|
4
|
+
const RULES = [
|
|
5
|
+
{
|
|
6
|
+
code: 'PODMAN_MACHINE_UNAVAILABLE',
|
|
7
|
+
pattern: /Cannot connect to Podman|unable to connect to Podman socket|podman machine start|podman system connection/i,
|
|
8
|
+
describe: ({ command }) => ({
|
|
9
|
+
reason: `当前 ${command} 命令正在连接 Podman machine,但连接不可用。`,
|
|
10
|
+
action: '请先在宿主机执行: podman machine start'
|
|
11
|
+
})
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
code: 'DOCKER_DAEMON_UNAVAILABLE',
|
|
15
|
+
pattern: /Cannot connect to the Docker daemon|docker daemon is not running|Is the docker daemon running/i,
|
|
16
|
+
describe: ({ command }) => ({
|
|
17
|
+
reason: `当前 ${command} 命令无法连接容器运行时。`,
|
|
18
|
+
action: '请先启动 Docker Desktop / Docker daemon,或确认 Podman machine 已启动。'
|
|
19
|
+
})
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
code: 'IMAGE_PULL_RATE_LIMITED',
|
|
23
|
+
pattern: /toomanyrequests|too many requests|rate limit/i,
|
|
24
|
+
describe: ({ imageRef }) => ({
|
|
25
|
+
reason: `拉取镜像${imageRef ? ` ${imageRef}` : ''}被镜像仓库限流(请求过于频繁)。`,
|
|
26
|
+
action: '稍等几分钟后重试;若在公共网络/代理出口下反复出现,换一个网络,或用离线安装包里自带的镜像。'
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
code: 'IMAGE_UNAUTHORIZED',
|
|
31
|
+
pattern: /\bunauthorized\b|authentication required|permission_denied|denied: installation not allowed/i,
|
|
32
|
+
describe: ({ imageRef }) => ({
|
|
33
|
+
reason: `镜像仓库拒绝了对${imageRef ? ` ${imageRef} ` : ' 镜像'}的匿名访问(未授权)。`,
|
|
34
|
+
action: '确认镜像名与版本无误;若是私有仓库,先执行 docker login / podman login;公开镜像仍报此错时,可能是过期的登录凭据,执行 logout 后重试。'
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: 'IMAGE_NOT_FOUND',
|
|
39
|
+
pattern: /manifest unknown|repository does not exist|pull access denied|requested access to the resource is denied|name unknown|not found: manifest/i,
|
|
40
|
+
describe: ({ imageRef }) => ({
|
|
41
|
+
reason: `镜像${imageRef ? ` ${imageRef}` : ''}不存在(仓库里没有这个版本,或没有访问权限)。`,
|
|
42
|
+
action: '检查 ~/.manyoyo/manyoyo.json 的 imageName / imageVersion;或先执行 manyoyo build --iv <x.y.z-后缀> 本地构建。'
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
code: 'IMAGE_PULL_FAILED',
|
|
47
|
+
pattern: /localhost\/v2|pinging container registry|connection refused|dial tcp .*:443|TLS handshake timeout|no such host|i\/o timeout/i,
|
|
48
|
+
describe: ({ imageRef }) => ({
|
|
49
|
+
reason: `拉取镜像${imageRef ? ` ${imageRef}` : ''}失败,无法连接镜像仓库。`,
|
|
50
|
+
action: '检查网络或代理后重试(国内网络可能需要代理或镜像加速);也可以先执行 manyoyo build 本地构建。'
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
code: 'PORT_IN_USE',
|
|
55
|
+
pattern: /port is already allocated|address already in use|EADDRINUSE/i,
|
|
56
|
+
describe: ({ port }) => ({
|
|
57
|
+
reason: `端口${port ? ` ${port}` : ''}已被占用。`,
|
|
58
|
+
action: '换一个端口,或关闭占用该端口的程序后重试。'
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
code: 'XCODE_CLT_MISSING',
|
|
63
|
+
// 只认真正的 CLT 缺失输出,不能因为输出里提到 xcode-select 就误判
|
|
64
|
+
pattern: /xcode-select: (error|note)|invalid active developer path|xcrun: error|command line tools are (not installed|required)/i,
|
|
65
|
+
describe: () => ({
|
|
66
|
+
reason: '该功能需要 Xcode 命令行工具。',
|
|
67
|
+
action: '请在终端执行: xcode-select --install,安装完成后重试。'
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} text 命令失败的原始输出
|
|
74
|
+
* @param {{command?: string, imageRef?: string, port?: number|string}} [context]
|
|
75
|
+
* @returns {{code: string, reason: string, action: string}|null}
|
|
76
|
+
*/
|
|
77
|
+
function describeError(text, context = {}) {
|
|
78
|
+
const raw = String(text || '');
|
|
79
|
+
const ctx = { command: 'docker', imageRef: '', port: '', ...context };
|
|
80
|
+
for (const rule of RULES) {
|
|
81
|
+
if (rule.pattern.test(raw)) {
|
|
82
|
+
return { code: rule.code, ...rule.describe(ctx) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatErrorHint(text, context = {}) {
|
|
89
|
+
const info = describeError(text, context);
|
|
90
|
+
return info ? `\n提示: ${info.reason}\n${info.action}` : '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
describeError,
|
|
95
|
+
formatErrorHint
|
|
96
|
+
};
|
package/lib/global-config.js
CHANGED
|
@@ -5,6 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const JSON5 = require('json5');
|
|
7
7
|
const { findTopLevelPropertyValueRange } = require('./json5-text-edit');
|
|
8
|
+
const { writeConfigFileSecure } = require('./secure-file');
|
|
8
9
|
|
|
9
10
|
function getManyoyoConfigPath(homeDir = os.homedir()) {
|
|
10
11
|
return path.join(homeDir, '.manyoyo', 'manyoyo.json');
|
|
@@ -91,17 +92,16 @@ function syncGlobalImageVersion(imageVersion, options = {}) {
|
|
|
91
92
|
imageVersion
|
|
92
93
|
};
|
|
93
94
|
|
|
94
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
95
95
|
if (result.exists) {
|
|
96
96
|
const currentText = fs.readFileSync(configPath, 'utf-8');
|
|
97
97
|
const updatedText = updateImageVersionText(currentText, imageVersion);
|
|
98
98
|
if (updatedText) {
|
|
99
|
-
|
|
99
|
+
writeConfigFileSecure(configPath, updatedText.endsWith('\n') ? updatedText : `${updatedText}\n`);
|
|
100
100
|
} else {
|
|
101
|
-
|
|
101
|
+
writeConfigFileSecure(configPath, `${JSON.stringify(nextConfig, null, 4)}\n`);
|
|
102
102
|
}
|
|
103
103
|
} else {
|
|
104
|
-
|
|
104
|
+
writeConfigFileSecure(configPath, `${JSON.stringify(nextConfig, null, 4)}\n`);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
return {
|
package/lib/headless.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 有头 / 无头判定:安装器与无参 `manyoyo` 启动器共用这一份规则。
|
|
4
|
+
// 有头 = 本机有图形界面且不是 SSH 会话,可以直接打开浏览器;无头 = 走命令行配置与端口转发。
|
|
5
|
+
|
|
6
|
+
function isSet(value) {
|
|
7
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseEnvForce(value) {
|
|
11
|
+
const text = String(value === undefined ? '' : value).trim().toLowerCase();
|
|
12
|
+
if (['1', 'true', 'yes'].includes(text)) return true;
|
|
13
|
+
if (['0', 'false', 'no'].includes(text)) return false;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{env?: Object, platform?: string, force?: boolean|null}} [options]
|
|
19
|
+
* force:命令行 --headless(true) / --gui(false),优先于环境变量 MANYOYO_HEADLESS,再优先于自动判定
|
|
20
|
+
* @returns {{headless: boolean, reason: string}}
|
|
21
|
+
*/
|
|
22
|
+
function detectHeadless(options = {}) {
|
|
23
|
+
const env = options.env || process.env;
|
|
24
|
+
const platform = options.platform || process.platform;
|
|
25
|
+
if (options.force === true) return { headless: true, reason: '命令行指定了 --headless' };
|
|
26
|
+
if (options.force === false) return { headless: false, reason: '命令行指定了 --gui' };
|
|
27
|
+
const forced = parseEnvForce(env.MANYOYO_HEADLESS);
|
|
28
|
+
if (forced !== null) return { headless: forced, reason: `环境变量 MANYOYO_HEADLESS=${forced ? 1 : 0}` };
|
|
29
|
+
if (isSet(env.SSH_CONNECTION) || isSet(env.SSH_TTY)) return { headless: true, reason: '当前是 SSH 会话' };
|
|
30
|
+
if (platform === 'linux' && !isSet(env.DISPLAY) && !isSet(env.WAYLAND_DISPLAY)) {
|
|
31
|
+
return { headless: true, reason: '没有检测到图形界面(DISPLAY / WAYLAND_DISPLAY 都为空)' };
|
|
32
|
+
}
|
|
33
|
+
return { headless: false, reason: '检测到图形界面' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 从 argv 取出 --headless / --gui;两者都出现或夹杂别的参数时返回 null,让调用方按普通子命令处理
|
|
37
|
+
function parseLauncherArgs(args) {
|
|
38
|
+
let force = null;
|
|
39
|
+
for (const arg of args) {
|
|
40
|
+
if (arg === '--headless') {
|
|
41
|
+
if (force === false) return null;
|
|
42
|
+
force = true;
|
|
43
|
+
} else if (arg === '--gui') {
|
|
44
|
+
if (force === true) return null;
|
|
45
|
+
force = false;
|
|
46
|
+
} else {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { force };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { detectHeadless, parseLauncherArgs, parseEnvForce };
|
package/lib/image-build.js
CHANGED
|
@@ -207,7 +207,8 @@ function prepareGoplsBuildCache(ctx, cache, imageTool, arch) {
|
|
|
207
207
|
cleanupGoTmpPath(ctx, tmpGoPath, false);
|
|
208
208
|
ensureDirectoryIfMissing(tmpGoPath);
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
// -trimpath:不把宿主机的绝对路径(如 /Users/<用户名>/go)编进公开分发的二进制
|
|
211
|
+
ctx.runCmd('go', ['install', '-trimpath', 'golang.org/x/tools/gopls@latest'], {
|
|
211
212
|
stdio: 'inherit',
|
|
212
213
|
env: { ...process.env, GOPATH: tmpGoPath, GOOS: 'linux', GOARCH: arch }
|
|
213
214
|
});
|
|
@@ -346,8 +347,8 @@ function runCmdPipeline(leftCmd, leftArgs, rightCmd, rightArgs, options = {}) {
|
|
|
346
347
|
|
|
347
348
|
return new Promise((resolve, reject) => {
|
|
348
349
|
let settled = false;
|
|
349
|
-
const left = spawn(leftCmd, leftArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
350
|
-
const right = spawn(rightCmd, rightArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
350
|
+
const left = spawn(leftCmd, leftArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: options.env });
|
|
351
|
+
const right = spawn(rightCmd, rightArgs, { stdio: ['pipe', 'pipe', 'pipe'], env: options.env });
|
|
351
352
|
right.stdin.on('error', () => {});
|
|
352
353
|
left.stdout.pipe(right.stdin);
|
|
353
354
|
|
|
@@ -498,7 +499,7 @@ async function updateAgentsInExistingImage(ctx, fullImageTag) {
|
|
|
498
499
|
fullImageTag,
|
|
499
500
|
'--format',
|
|
500
501
|
'{{json .Config}}'
|
|
501
|
-
], { stdio: 'pipe' }));
|
|
502
|
+
], { stdio: 'pipe', env: ctx.dockerEnv }));
|
|
502
503
|
} catch (e) {
|
|
503
504
|
ctx.error(`${RED}错误: 找不到本地镜像 ${fullImageTag}${NC}`);
|
|
504
505
|
ctx.error(`${YELLOW}请先执行 ${ctx.manyoyoName} build --iv ${fullImageTag.split(':').pop()} --yes 构建镜像。${NC}`);
|
|
@@ -538,8 +539,8 @@ async function updateAgentsInExistingImage(ctx, fullImageTag) {
|
|
|
538
539
|
|
|
539
540
|
const updateStartTime = Date.now();
|
|
540
541
|
try {
|
|
541
|
-
ctx.runCmd(ctx.dockerCmd, runArgs, { stdio: 'inherit' });
|
|
542
|
-
await ctx.runCmdPipeline(ctx.dockerCmd, ['export', containerName], ctx.dockerCmd, importArgs, { stdio: 'inherit' });
|
|
542
|
+
ctx.runCmd(ctx.dockerCmd, runArgs, { stdio: 'inherit', env: ctx.dockerEnv });
|
|
543
|
+
await ctx.runCmdPipeline(ctx.dockerCmd, ['export', containerName], ctx.dockerCmd, importArgs, { stdio: 'inherit', env: ctx.dockerEnv });
|
|
543
544
|
const updateDuration = ((Date.now() - updateStartTime) / 1000).toFixed(1);
|
|
544
545
|
ctx.log(`\n${GREEN}✅ Agent CLI 更新成功: ${fullImageTag}${NC}`);
|
|
545
546
|
ctx.log(`${GREEN}⏱️ 更新耗时: ${updateDuration} 秒${NC}`);
|
|
@@ -548,7 +549,7 @@ async function updateAgentsInExistingImage(ctx, fullImageTag) {
|
|
|
548
549
|
ctx.exit(1);
|
|
549
550
|
} finally {
|
|
550
551
|
try {
|
|
551
|
-
ctx.runCmd(ctx.dockerCmd, cleanupArgs, { stdio: 'inherit', ignoreError: true });
|
|
552
|
+
ctx.runCmd(ctx.dockerCmd, cleanupArgs, { stdio: 'inherit', ignoreError: true, env: ctx.dockerEnv });
|
|
552
553
|
} catch (e) {}
|
|
553
554
|
}
|
|
554
555
|
}
|
|
@@ -566,6 +567,7 @@ async function buildImage(options = {}) {
|
|
|
566
567
|
updateAgents: Boolean(options.updateAgents),
|
|
567
568
|
agentUpdateContainerName: options.agentUpdateContainerName || '',
|
|
568
569
|
dockerCmd: options.dockerCmd || 'docker',
|
|
570
|
+
dockerEnv: options.dockerEnv,
|
|
569
571
|
rootDir: options.rootDir || process.cwd(),
|
|
570
572
|
loadConfig: options.loadConfig || (() => ({})),
|
|
571
573
|
runCmd: options.runCmd,
|
|
@@ -649,7 +651,7 @@ async function buildImage(options = {}) {
|
|
|
649
651
|
}
|
|
650
652
|
|
|
651
653
|
try {
|
|
652
|
-
ctx.runCmd(ctx.dockerCmd, buildArgs, { stdio: 'inherit' });
|
|
654
|
+
ctx.runCmd(ctx.dockerCmd, buildArgs, { stdio: 'inherit', env: ctx.dockerEnv });
|
|
653
655
|
logBuildSuccess();
|
|
654
656
|
return;
|
|
655
657
|
} catch (e) {
|
|
@@ -676,7 +678,7 @@ async function buildImage(options = {}) {
|
|
|
676
678
|
}
|
|
677
679
|
|
|
678
680
|
try {
|
|
679
|
-
await ctx.runCmdPipeline(ctx.dockerCmd, buildkitRunArgs, ctx.dockerCmd, ['load'], { stdio: 'inherit' });
|
|
681
|
+
await ctx.runCmdPipeline(ctx.dockerCmd, buildkitRunArgs, ctx.dockerCmd, ['load'], { stdio: 'inherit', env: ctx.dockerEnv });
|
|
680
682
|
logBuildSuccess();
|
|
681
683
|
} catch (e) {
|
|
682
684
|
ctx.error(`${RED}错误: 镜像构建失败${NC}`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 清理悬空镜像。绝不能用 `rmi -f`:Podman 的 `rmi --force` 会连带删除正在用该镜像的容器,
|
|
4
|
+
// 用户的旧容器(里面可能有没导出的工作成果)就这样没了。这里只做不带 -f 的清理,
|
|
5
|
+
// 被容器引用的镜像删不掉是预期行为,直接跳过。
|
|
6
|
+
|
|
7
|
+
function pruneDanglingImages({ dockerExecArgs, log = () => {}, colors = {} }) {
|
|
8
|
+
const { YELLOW = '', GREEN = '', NC = '' } = colors;
|
|
9
|
+
log(`\n${YELLOW}清理悬空镜像...${NC}`);
|
|
10
|
+
// image prune 只清未被任何容器引用的悬空镜像
|
|
11
|
+
try {
|
|
12
|
+
dockerExecArgs(['image', 'prune', '-f'], { stdio: 'inherit' });
|
|
13
|
+
} catch (error) {
|
|
14
|
+
// daemon 不可用等:清理是尽力而为,不要把一次维护命令变成异常
|
|
15
|
+
log(`${YELLOW}⚠️ image prune 失败,已跳过: ${String(error && error.message || error).split('\n')[0]}${NC}`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const imagesOutput = dockerExecArgs(['images', '-a', '--format', '{{.ID}} {{.Repository}}']);
|
|
21
|
+
const noneImages = [...new Set(String(imagesOutput)
|
|
22
|
+
.split('\n')
|
|
23
|
+
.filter(line => line.includes('<none>'))
|
|
24
|
+
.map(line => line.split(' ')[0])
|
|
25
|
+
.filter(id => id))];
|
|
26
|
+
|
|
27
|
+
if (noneImages.length > 0) {
|
|
28
|
+
log(`${YELLOW}尝试清理剩余的 <none> 镜像 (${noneImages.length} 个,仍被容器使用的会自动跳过)...${NC}`);
|
|
29
|
+
for (const id of noneImages) {
|
|
30
|
+
try {
|
|
31
|
+
dockerExecArgs(['rmi', id], { stdio: 'pipe' });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// 被容器使用或被其它镜像依赖:保留
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// 列不出镜像就算了
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log(`${GREEN}✅ 清理完成${NC}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { pruneDanglingImages };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const { describeError } = require('./error-hints');
|
|
5
|
+
|
|
6
|
+
const OUTPUT_TAIL_CHARS = 4096;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 拉取镜像,输出实时回调(CLI 直接透传终端,Web 取最后一行当进度)。
|
|
10
|
+
* 失败时 reject,err.output 是输出末尾,供分类提示。
|
|
11
|
+
*/
|
|
12
|
+
function pullImageProcess(options) {
|
|
13
|
+
const { command, env, imageRef, onOutput } = options;
|
|
14
|
+
const spawnFn = options.spawnFn || spawn;
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let tail = '';
|
|
17
|
+
const child = spawnFn(command, ['pull', imageRef], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
18
|
+
const collect = stream => chunk => {
|
|
19
|
+
const text = chunk.toString('utf-8');
|
|
20
|
+
tail = (tail + text).slice(-OUTPUT_TAIL_CHARS);
|
|
21
|
+
if (onOutput) onOutput(text, stream);
|
|
22
|
+
};
|
|
23
|
+
child.stdout.on('data', collect('stdout'));
|
|
24
|
+
child.stderr.on('data', collect('stderr'));
|
|
25
|
+
child.on('error', error => {
|
|
26
|
+
const err = new Error(error.message);
|
|
27
|
+
err.output = `${tail}\n${error.message}`;
|
|
28
|
+
reject(err);
|
|
29
|
+
});
|
|
30
|
+
child.on('close', code => {
|
|
31
|
+
if (code === 0) {
|
|
32
|
+
resolve();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const err = new Error(`${command} pull ${imageRef} 退出码 ${code}`);
|
|
36
|
+
err.output = tail;
|
|
37
|
+
reject(err);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toFriendlyPullError(error, imageRef, command) {
|
|
43
|
+
const text = `${error && error.output || ''}\n${error && error.message || ''}`;
|
|
44
|
+
const info = describeError(text, { imageRef, command });
|
|
45
|
+
const friendly = new Error(info
|
|
46
|
+
? `${info.reason}\n${info.action}`
|
|
47
|
+
: `拉取镜像 ${imageRef} 失败: ${String(error && error.message || error).split('\n')[0]}`);
|
|
48
|
+
friendly.code = info ? info.code : 'IMAGE_PULL_FAILED';
|
|
49
|
+
friendly.cause = error;
|
|
50
|
+
return friendly;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 本地没有镜像就拉取;已有则什么都不做。
|
|
55
|
+
* 失败抛出带“原因 + 下一步”的错误:运行时不可用 / 镜像不存在 / 网络问题。
|
|
56
|
+
* @returns {Promise<{pulled: boolean}>}
|
|
57
|
+
*/
|
|
58
|
+
async function ensureImagePresent(options) {
|
|
59
|
+
const { imageRef, command, isPresent, pull, onStart } = options;
|
|
60
|
+
if (await isPresent()) {
|
|
61
|
+
return { pulled: false };
|
|
62
|
+
}
|
|
63
|
+
if (onStart) onStart();
|
|
64
|
+
try {
|
|
65
|
+
await pull();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw toFriendlyPullError(error, imageRef, command);
|
|
68
|
+
}
|
|
69
|
+
return { pulled: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
pullImageProcess,
|
|
74
|
+
toFriendlyPullError,
|
|
75
|
+
ensureImagePresent
|
|
76
|
+
};
|