@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,138 @@
1
+ 'use strict';
2
+
3
+ // 私有 Podman 设了 CONTAINERS_CONF,不会再读用户自己的 ~/.config/containers/containers.conf,
4
+ // 所以安装时把其中“有用的”代理设置抄过来:只抄代理相关项,其它(仓库、存储路径等)一概不碰。
5
+ // 用法(安装器调用):node proxy-config.js --write-conf <私有podman目录> <用户HOME>
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+
11
+ const PROXY_NAMES = ['http_proxy', 'https_proxy', 'no_proxy', 'all_proxy'];
12
+
13
+ function isSafeValue(value) {
14
+ // 会写进 TOML 字符串,也可能进命令环境:拒绝换行、引号、反斜杠与控制字符
15
+ return typeof value === 'string' && value.length > 0 && value.length < 2048 && !/[\r\n\0"'\\`$]/.test(value);
16
+ }
17
+
18
+ function sectionBody(text, name) {
19
+ const lines = text.split('\n');
20
+ let current = '';
21
+ const body = [];
22
+ for (const line of lines) {
23
+ const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
24
+ if (header) { current = header[1].trim(); continue; }
25
+ if (current === name) body.push(line);
26
+ }
27
+ return body.join('\n');
28
+ }
29
+
30
+ /**
31
+ * 从用户的 containers.conf 文本里取出代理环境变量([engine] env = [...],可多行)与 [containers] http_proxy 开关。
32
+ * @returns {{env: Object<string,string>, passToContainers: boolean|null}}
33
+ */
34
+ function parseUserContainersConf(text) {
35
+ const result = { env: {}, passToContainers: null, dropped: [] };
36
+ const engine = sectionBody(String(text || ''), 'engine');
37
+ // 数组里的字符串可能含 ](如 IPv6 代理 http://[::1]:7890),所以按“引号串 或 非 ] 字符”匹配
38
+ const envMatch = engine.match(/^\s*env\s*=\s*\[((?:"(?:[^"\\\n]|\\.)*"|[^\]"])*)\]/m);
39
+ if (envMatch) {
40
+ for (const entry of envMatch[1].matchAll(/"((?:[^"\\\n]|\\.)*)"/g)) {
41
+ const index = entry[1].indexOf('=');
42
+ if (index <= 0) continue;
43
+ const name = entry[1].slice(0, index).trim().toLowerCase();
44
+ const value = entry[1].slice(index + 1).trim();
45
+ if (!PROXY_NAMES.includes(name)) continue;
46
+ if (isSafeValue(value)) result.env[name] = value;
47
+ else if (value) result.dropped.push(name);
48
+ }
49
+ }
50
+ const containers = sectionBody(String(text || ''), 'containers');
51
+ const flag = containers.match(/^\s*http_proxy\s*=\s*(true|false)\s*$/m);
52
+ if (flag) result.passToContainers = flag[1] === 'true';
53
+ return result;
54
+ }
55
+
56
+ function proxyFromEnvironment(env, dropped = []) {
57
+ const found = {};
58
+ for (const name of PROXY_NAMES) {
59
+ const value = env[name] || env[name.toUpperCase()];
60
+ if (isSafeValue(value)) found[name] = value;
61
+ else if (value) dropped.push(name);
62
+ }
63
+ return found;
64
+ }
65
+
66
+ /**
67
+ * 汇总代理:用户的 containers.conf 优先(那是对 Podman 实际生效的配置),环境变量补缺。
68
+ */
69
+ function collectProxySettings({ homeDir = os.homedir(), env = process.env, readFile = file => fs.readFileSync(file, 'utf8') } = {}) {
70
+ let fromFile = { env: {}, passToContainers: null };
71
+ const userConf = path.join(homeDir, '.config', 'containers', 'containers.conf');
72
+ try {
73
+ fromFile = parseUserContainersConf(readFile(userConf));
74
+ } catch (error) {
75
+ // 没有用户配置很正常
76
+ }
77
+ const dropped = [];
78
+ const fromEnv = proxyFromEnvironment(env, dropped);
79
+ const merged = { ...fromEnv, ...fromFile.env };
80
+ // 被丢弃的项只有在最终没有可用值时才算“丢了”(只报名字,不打印值:可能带密码)
81
+ return {
82
+ env: merged,
83
+ passToContainers: fromFile.passToContainers,
84
+ dropped: [...new Set([...dropped, ...(fromFile.dropped || [])])].filter(name => !(name in merged))
85
+ };
86
+ }
87
+
88
+ function renderContainersConf({ podmanRoot, proxy }) {
89
+ const lines = ['[engine]', `helper_binaries_dir = [${JSON.stringify(path.join(podmanRoot, 'bin'))}]`];
90
+ const entries = Object.entries(proxy.env).map(([name, value]) => JSON.stringify(`${name}=${value}`));
91
+ if (entries.length > 0) lines.push(`env = [${entries.join(', ')}]`);
92
+ if (proxy.passToContainers !== null) lines.push('', '[containers]', `http_proxy = ${proxy.passToContainers}`);
93
+ return `${lines.join('\n')}\n`;
94
+ }
95
+
96
+ /**
97
+ * 写 <podmanRoot>/containers.conf。每次安装都重新生成(代理会变),
98
+ * 但只覆盖带本工具标记的文件,用户手改过的不动。
99
+ */
100
+ function writePrivateContainersConf({ podmanRoot, homeDir, env = process.env }) {
101
+ const target = path.join(podmanRoot, 'containers.conf');
102
+ const marker = '# generated by manyoyo installer';
103
+ if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').startsWith(marker)) {
104
+ const legacy = fs.readFileSync(target, 'utf8');
105
+ // lib/container-runtime.js 首次选择时写的最小配置(只有 helper_binaries_dir)可以安全升级
106
+ if (/^\[engine\]\nhelper_binaries_dir = \[[^\n]*\]\n$/.test(legacy) === false) {
107
+ return { path: target, written: false, reason: 'user-modified' };
108
+ }
109
+ }
110
+ const proxy = collectProxySettings({ homeDir, env });
111
+ fs.mkdirSync(podmanRoot, { recursive: true });
112
+ // 代理 URL 里可能带账号密码,和用户自己的 containers.conf 一样只给本人读写
113
+ fs.writeFileSync(target, `${marker}\n${renderContainersConf({ podmanRoot, proxy })}`, { mode: 0o600 });
114
+ fs.chmodSync(target, 0o600);
115
+ return { path: target, written: true, proxyNames: Object.keys(proxy.env), passToContainers: proxy.passToContainers, droppedNames: proxy.dropped };
116
+ }
117
+
118
+ module.exports = {
119
+ parseUserContainersConf,
120
+ collectProxySettings,
121
+ renderContainersConf,
122
+ writePrivateContainersConf
123
+ };
124
+
125
+ if (require.main === module) {
126
+ const [flag, podmanRoot, homeDir] = process.argv.slice(2);
127
+ if (flag !== '--write-conf' || !podmanRoot) {
128
+ console.error('用法: node proxy-config.js --write-conf <私有podman目录> [用户HOME]');
129
+ process.exit(2);
130
+ }
131
+ const result = writePrivateContainersConf({ podmanRoot, homeDir: homeDir || os.homedir() });
132
+ console.log(result.written
133
+ ? `已生成 ${result.path}(代理项: ${result.proxyNames.join(', ') || '无'})`
134
+ : `保留用户修改过的 ${result.path}`);
135
+ if (result.droppedNames && result.droppedNames.length > 0) {
136
+ console.log(`注意:以下代理项含引号、反斜杠、$ 或反引号等不安全字符,没有抄进私有 Podman 配置: ${result.droppedNames.join(', ')}(需要的话请手动编辑 ${result.path})`);
137
+ }
138
+ }
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+
6
+ // 离线安装器给私有 Podman 建的 machine 名字;列表里有它就优先启动它
7
+ const PRIVATE_MACHINE_NAME = 'podman-machine-manyoyo';
8
+ // 冷启动或磁盘忙时 info 可能超过 3 秒但 daemon 是好的
9
+ const PROBE_TIMEOUT_MS = 10000;
10
+ const DEFAULT_READY_TIMEOUT_MS = 120000;
11
+ const DEFAULT_POLL_MS = 2000;
12
+
13
+ function defaultSleep(ms) {
14
+ return new Promise(resolve => setTimeout(resolve, ms));
15
+ }
16
+
17
+ /**
18
+ * 运行时 daemon 不可用时尝试自愈:
19
+ * - Podman:machine 已存在但未运行 → podman machine start
20
+ * - macOS 上的 Docker:open -a Docker 并等待就绪
21
+ * 其余情况只报告,不猜。
22
+ *
23
+ * @param {Object} options
24
+ * @param {{command: string, env?: Object}} options.runtime selectContainerRuntime 的结果
25
+ * @param {(command: string, args: string[], opts: {env?: Object, timeout?: number}) => string|Promise<string>} options.run 执行器(同步或异步均可),失败抛错;serve 里必须传异步实现,避免阻塞事件循环
26
+ * @param {(state: {status: string, message: string}) => void} [options.onStatus]
27
+ * @returns {Promise<{status: 'ready'|'started'|'unavailable'|'failed'|'timeout', message: string}>}
28
+ */
29
+ async function ensureRuntimeReady(options) {
30
+ const { runtime, run } = options;
31
+ const platform = options.platform || process.platform;
32
+ const sleep = options.sleep || defaultSleep;
33
+ const now = options.now || Date.now;
34
+ const onStatus = options.onStatus || (() => {});
35
+ const timeoutMs = options.timeoutMs || DEFAULT_READY_TIMEOUT_MS;
36
+ const pollMs = options.pollMs || DEFAULT_POLL_MS;
37
+ const env = runtime.env || {};
38
+ const kind = path.basename(runtime.command);
39
+
40
+ let lastProbeTimedOut = false;
41
+ const isReady = async () => {
42
+ try {
43
+ await run(runtime.command, ['info'], { env, timeout: PROBE_TIMEOUT_MS });
44
+ lastProbeTimedOut = false;
45
+ return true;
46
+ } catch (e) {
47
+ lastProbeTimedOut = Boolean(e && (e.code === 'ETIMEDOUT' || e.timedOut || e.killed));
48
+ return false;
49
+ }
50
+ };
51
+
52
+ if (await isReady()) {
53
+ return { status: 'ready', message: '' };
54
+ }
55
+
56
+ let startMessage;
57
+ if (kind === 'podman') {
58
+ let machines = [];
59
+ try {
60
+ machines = JSON.parse(await run(runtime.command, ['machine', 'list', '--format', 'json'], { env, timeout: 10000 }));
61
+ } catch (e) {
62
+ machines = [];
63
+ }
64
+ if (!Array.isArray(machines) || machines.length === 0) {
65
+ return { status: 'unavailable', message: '未找到 Podman machine,请先执行: podman machine init' };
66
+ }
67
+ const starting = machines.some(machine => machine && machine.Starting);
68
+ if (!starting && machines.some(machine => machine && machine.Running)) {
69
+ return {
70
+ status: 'unavailable',
71
+ message: lastProbeTimedOut
72
+ ? 'Podman machine 已在运行但响应超时,请稍后重试;持续超时再执行: podman machine stop && podman machine start'
73
+ : 'Podman machine 已在运行但连接不可用,请执行: podman machine stop && podman machine start'
74
+ };
75
+ }
76
+ const clean = machine => String(machine && machine.Name || '').replace(/\*$/, '');
77
+ const target = machines.find(machine => clean(machine) === PRIVATE_MACHINE_NAME)
78
+ || machines.find(machine => machine && machine.Default) || machines[0];
79
+ const name = String(target.Name || '').replace(/\*$/, '');
80
+ startMessage = '正在启动容器环境 (podman machine start)';
81
+ onStatus({ status: 'starting', message: startMessage });
82
+ // 别处(另一个 CLI/serve)已经在启动同一台 machine:不再重复 start,直接轮询等待
83
+ if (!starting) {
84
+ try {
85
+ await run(runtime.command, name ? ['machine', 'start', name] : ['machine', 'start'], { env, timeout: timeoutMs });
86
+ } catch (e) {
87
+ // 并发启动时 start 会报 already starting/running 之类:先重探一次再判失败
88
+ if (await isReady()) {
89
+ onStatus({ status: 'ready', message: '' });
90
+ return { status: 'started', message: startMessage };
91
+ }
92
+ const message = `podman machine start 失败: ${String(e && e.message || e).split('\n')[0]}`;
93
+ onStatus({ status: 'failed', message });
94
+ return { status: 'failed', message };
95
+ }
96
+ }
97
+ } else if (kind === 'docker' && platform === 'darwin') {
98
+ startMessage = '正在启动容器环境 (Docker Desktop)';
99
+ onStatus({ status: 'starting', message: startMessage });
100
+ try {
101
+ await run('open', ['-a', 'Docker'], { timeout: 10000 });
102
+ } catch (e) {
103
+ const message = '无法启动 Docker Desktop,请手动打开后重试';
104
+ onStatus({ status: 'failed', message });
105
+ return { status: 'failed', message };
106
+ }
107
+ } else {
108
+ return { status: 'unavailable', message: `${kind} daemon 不可用,请先启动后重试` };
109
+ }
110
+
111
+ const deadline = now() + timeoutMs;
112
+ while (now() < deadline) {
113
+ if (await isReady()) {
114
+ onStatus({ status: 'ready', message: '' });
115
+ return { status: 'started', message: startMessage };
116
+ }
117
+ await sleep(pollMs);
118
+ }
119
+ const message = `等待容器环境就绪超时(${Math.round(timeoutMs / 1000)} 秒),请确认 ${kind === 'podman' ? 'Podman machine' : 'Docker Desktop'} 已启动后重试`;
120
+ onStatus({ status: 'failed', message });
121
+ return { status: 'timeout', message };
122
+ }
123
+
124
+ /**
125
+ * 异步执行命令(不阻塞事件循环)。语义与同步执行器一致:非 0 退出或超时都抛错,
126
+ * 超时的错误带 code=ETIMEDOUT。serve 里的自愈必须用它,不能用 spawnSync。
127
+ * @returns {Promise<string>} stdout
128
+ */
129
+ function runCommandAsync(command, args, options = {}) {
130
+ return new Promise((resolve, reject) => {
131
+ let child;
132
+ try {
133
+ child = spawn(command, args, { env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
134
+ } catch (e) {
135
+ reject(e);
136
+ return;
137
+ }
138
+ let stdout = '';
139
+ let stderr = '';
140
+ let timedOut = false;
141
+ const timer = options.timeout ? setTimeout(() => {
142
+ timedOut = true;
143
+ child.kill('SIGKILL');
144
+ }, options.timeout) : null;
145
+ child.stdout.on('data', chunk => { stdout += chunk; });
146
+ child.stderr.on('data', chunk => { stderr += chunk; });
147
+ child.on('error', error => {
148
+ if (timer) clearTimeout(timer);
149
+ reject(error);
150
+ });
151
+ child.on('close', code => {
152
+ if (timer) clearTimeout(timer);
153
+ if (timedOut) {
154
+ const error = new Error(`Command timed out: ${command} ${args.join(' ')}`);
155
+ error.code = 'ETIMEDOUT';
156
+ error.stdout = stdout;
157
+ error.stderr = stderr;
158
+ reject(error);
159
+ } else if (code !== 0) {
160
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
161
+ error.status = code;
162
+ error.stdout = stdout;
163
+ error.stderr = stderr;
164
+ reject(error);
165
+ } else {
166
+ resolve(stdout);
167
+ }
168
+ });
169
+ });
170
+ }
171
+
172
+ module.exports = {
173
+ ensureRuntimeReady,
174
+ runCommandAsync
175
+ };
@@ -58,7 +58,57 @@ function normalizeVolume(volume, homeDir = os.homedir()) {
58
58
  return `${expandHomeAliasPath(hostPath, homeDir)}${rest}`;
59
59
  }
60
60
 
61
+ const MIRROR_KEYS = ['apt', 'npm', 'pip'];
62
+ const MIRROR_URL_MAX_LENGTH = 256;
63
+
64
+ /**
65
+ * 归一化全局配置 mirrors:{ apt, npm, pip },空/缺省 = 官方默认(返回空字符串)。
66
+ * 只接受 http/https URL;这些值会进入容器环境变量或 apt 配置,所以严格拒绝空白和 shell 元字符。
67
+ */
68
+ function normalizeMirrors(value) {
69
+ const result = { apt: '', npm: '', pip: '' };
70
+ if (value === undefined || value === null) {
71
+ return result;
72
+ }
73
+ if (typeof value !== 'object' || Array.isArray(value)) {
74
+ throw new Error('mirrors 必须是对象,例如 { "npm": "https://registry.npmmirror.com/" }');
75
+ }
76
+ for (const key of Object.keys(value)) {
77
+ if (!MIRROR_KEYS.includes(key)) {
78
+ throw new Error(`mirrors 不支持的键: ${key}(只支持 ${MIRROR_KEYS.join(' / ')})`);
79
+ }
80
+ const raw = value[key];
81
+ if (raw === undefined || raw === null) {
82
+ continue;
83
+ }
84
+ if (typeof raw !== 'string') {
85
+ throw new Error(`mirrors.${key} 必须是字符串`);
86
+ }
87
+ const text = raw.trim();
88
+ if (!text) {
89
+ continue;
90
+ }
91
+ if (!/^https?:\/\//i.test(text)) {
92
+ throw new Error(`mirrors.${key} 必须以 http:// 或 https:// 开头: ${text.slice(0, 40)}`);
93
+ }
94
+ if (text.length > MIRROR_URL_MAX_LENGTH) {
95
+ throw new Error(`mirrors.${key} 过长(最多 ${MIRROR_URL_MAX_LENGTH} 个字符)`);
96
+ }
97
+ if (/[\s\0;&|`$<>'"\\]/.test(text)) {
98
+ throw new Error(`mirrors.${key} 含非法字符(不允许空白、引号、反斜杠和 ; & | \` $ < >)`);
99
+ }
100
+ try {
101
+ new URL(text);
102
+ } catch (e) {
103
+ throw new Error(`mirrors.${key} 不是有效的 URL`);
104
+ }
105
+ result[key] = text;
106
+ }
107
+ return result;
108
+ }
109
+
61
110
  module.exports = {
111
+ normalizeMirrors,
62
112
  parseEnvEntry,
63
113
  expandHomeAliasPath,
64
114
  normalizeVolume
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ /**
8
+ * 写含凭据的配置文件:文件 0600、目录 0700,临时文件 + rename 原子替换(崩溃不会留下被截断的配置)。
9
+ * 目录只在“新建”或名为 .manyoyo 时收紧,不去改动用户指定的其它已有目录的权限。
10
+ * @param {string} filePath
11
+ * @param {string} data
12
+ */
13
+ function writeConfigFileSecure(filePath, data) {
14
+ if (typeof data !== 'string') {
15
+ throw new TypeError('配置内容必须是字符串');
16
+ }
17
+ const dir = path.dirname(filePath);
18
+ const existed = fs.existsSync(dir);
19
+ if (!existed) {
20
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ }
22
+ if (!existed || path.basename(dir) === '.manyoyo') {
23
+ try { fs.chmodSync(dir, 0o700); } catch (e) { /* 非属主等情况忽略 */ }
24
+ }
25
+ let target = filePath;
26
+ try { target = fs.realpathSync(filePath); } catch (e) { /* 新文件 */ }
27
+ const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
28
+ try {
29
+ fs.writeFileSync(tmp, data, { mode: 0o600, flag: 'wx' });
30
+ fs.chmodSync(tmp, 0o600);
31
+ fs.renameSync(tmp, target);
32
+ } catch (error) {
33
+ try { fs.rmSync(tmp, { force: true }); } catch (e) { /* 尽力清理 */ }
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ module.exports = { writeConfigFileSecure };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ // `manyoyo setup`:无头环境(SSH、没有图形界面)的命令行配置向导,等价于网页向导的几个步骤。
4
+ // 配置文本的构造复用 lib/setup-config.js 与 lib/setup.js(和 /api/setup/* 同源),写入走 secure-file(0600、原子)。
5
+ // 所有 IO 都从 deps 注入,便于测试;密钥与密码不回显、不写日志、不打印。
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const readline = require('readline');
10
+ const { Writable } = require('stream');
11
+ const JSON5 = require('json5');
12
+ const { listSetupAgents, buildAgentRunProfile, validateSetupPassword, listMirrorPresets } = require('./setup');
13
+ const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw } = require('./setup-config');
14
+ const { normalizeMirrors } = require('./runtime-normalizers');
15
+ const { writeConfigFileSecure } = require('./secure-file');
16
+ const { getManyoyoConfigPath } = require('./global-config');
17
+
18
+ class InputClosedError extends Error {}
19
+
20
+ /**
21
+ * 读 stdin 的提示器。askSecret 输入不回显:提示文字直接写到真实 output,
22
+ * 回显通道(readline 的 output)在读密码期间静音。
23
+ */
24
+ function createPrompter({ input = process.stdin, output = process.stdout, terminal = Boolean(input.isTTY) } = {}) {
25
+ let muted = false;
26
+ const echo = new Writable({
27
+ write(chunk, encoding, callback) {
28
+ if (!muted) output.write(chunk, encoding);
29
+ callback();
30
+ }
31
+ });
32
+ const rl = readline.createInterface({ input, output: echo, terminal });
33
+ let closed = false;
34
+ let rejectPending = null;
35
+ rl.on('close', () => {
36
+ closed = true;
37
+ if (rejectPending) rejectPending(new InputClosedError('输入已结束'));
38
+ });
39
+ const question = prompt => new Promise((resolve, reject) => {
40
+ if (closed) {
41
+ reject(new InputClosedError('输入已结束'));
42
+ return;
43
+ }
44
+ rejectPending = reject;
45
+ rl.question(prompt, answer => {
46
+ rejectPending = null;
47
+ resolve(answer);
48
+ });
49
+ });
50
+ return {
51
+ ask: text => question(text),
52
+ async askSecret(text) {
53
+ output.write(text);
54
+ muted = true;
55
+ try {
56
+ return await question('');
57
+ } finally {
58
+ muted = false;
59
+ output.write('\n');
60
+ }
61
+ },
62
+ close: () => rl.close()
63
+ };
64
+ }
65
+
66
+ async function chooseNumber(prompter, log, title, items, { defaultIndex = 0, allowSkip = false } = {}) {
67
+ log(title);
68
+ items.forEach((item, index) => log(` ${index + 1}) ${item}`));
69
+ if (allowSkip) log(' 0) 跳过 / 官方默认');
70
+ for (;;) {
71
+ const answer = String(await prompter.ask(`请选择${allowSkip ? '' : ` [${defaultIndex + 1}]`}: `)).trim();
72
+ if (answer === '' && !allowSkip) return defaultIndex;
73
+ if (allowSkip && (answer === '' || answer === '0')) return -1;
74
+ const index = Number(answer) - 1;
75
+ if (Number.isInteger(index) && index >= 0 && index < items.length) return index;
76
+ log('请输入列表里的编号。');
77
+ }
78
+ }
79
+
80
+ async function askMirrors(prompter, log) {
81
+ const presets = listMirrorPresets();
82
+ const toolNames = { apt: 'apt(Ubuntu 软件包)', npm: 'npm', pip: 'pip(Python)' };
83
+ const mirrors = {};
84
+ for (const tool of ['apt', 'npm', 'pip']) {
85
+ const items = [...presets[tool].map(item => `${item.label} ${item.value}`), '自定义地址'];
86
+ const choice = await chooseNumber(prompter, log, `${toolNames[tool]} 软件源:`, items, { allowSkip: true });
87
+ if (choice === -1) continue;
88
+ if (choice < presets[tool].length) {
89
+ mirrors[tool] = presets[tool][choice].value;
90
+ continue;
91
+ }
92
+ for (;;) {
93
+ const value = String(await prompter.ask('请输入 http(s) 地址(留空取消): ')).trim();
94
+ if (!value) break;
95
+ try {
96
+ mirrors[tool] = normalizeMirrors({ [tool]: value })[tool];
97
+ break;
98
+ } catch (error) {
99
+ log(error.message);
100
+ }
101
+ }
102
+ }
103
+ return mirrors;
104
+ }
105
+
106
+ async function askPassword(prompter, log) {
107
+ for (let attempt = 0; attempt < 3; attempt += 1) {
108
+ const first = await prompter.askSecret('设置登录密码(至少 8 位,输入不回显): ');
109
+ const reason = validateSetupPassword(first);
110
+ if (reason) {
111
+ log(reason);
112
+ continue;
113
+ }
114
+ const second = await prompter.askSecret('再输入一次: ');
115
+ if (first !== second) {
116
+ log('两次输入不一致,请重新设置。');
117
+ continue;
118
+ }
119
+ return first;
120
+ }
121
+ throw new Error('登录密码设置失败次数过多,已退出。');
122
+ }
123
+
124
+ /**
125
+ * @returns {Promise<number>} 退出码
126
+ */
127
+ async function runSetupCli(deps) {
128
+ const log = deps.log || (line => console.log(line));
129
+ const commandName = deps.commandName || 'manyoyo';
130
+ if (!deps.isTTY) {
131
+ log(`${commandName} setup 需要在交互式终端里运行(当前输入不是终端)。`);
132
+ log('可选做法:');
133
+ log(' 1. 在能交互的终端(例如 ssh 登录后的 shell)里重新运行 setup;');
134
+ log(` 2. 在有图形界面的环境里运行 ${commandName},用网页向导完成配置;`);
135
+ log(` 3. 直接编辑 ${deps.configPath}(可先用 ${commandName} init 生成默认配置)。`);
136
+ return 1;
137
+ }
138
+ const prompter = deps.prompter;
139
+ try {
140
+ const agents = listSetupAgents();
141
+ const agentIndex = await chooseNumber(prompter, log, '选择要配置的 Agent:', agents.map(item => `${item.label}(${item.id})`));
142
+ const agent = agents[agentIndex];
143
+
144
+ const env = {};
145
+ let keyName = agent.requiredAnyOf[0];
146
+ if (agent.requiredAnyOf.length > 1) {
147
+ const keyIndex = await chooseNumber(prompter, log, '使用哪种凭据:', agent.requiredAnyOf.map(name => {
148
+ const entry = agent.env.find(item => item.name === name);
149
+ return `${name} ${entry ? entry.description : ''}`;
150
+ }));
151
+ keyName = agent.requiredAnyOf[keyIndex];
152
+ }
153
+ const key = String(await prompter.askSecret(`${keyName}(输入不回显): `)).trim();
154
+ if (!key) {
155
+ log('凭据不能为空,已退出,没有改动任何配置。');
156
+ return 1;
157
+ }
158
+ env[keyName] = key;
159
+
160
+ const baseUrlItems = [...agent.baseUrlPresets.map(item => `${item.label} ${item.value}`), '自定义地址'];
161
+ const baseIndex = await chooseNumber(prompter, log, 'API 地址:', baseUrlItems, { allowSkip: true });
162
+ if (baseIndex >= 0 && baseIndex < agent.baseUrlPresets.length) {
163
+ env[agent.baseUrlKey] = agent.baseUrlPresets[baseIndex].value;
164
+ } else if (baseIndex === agent.baseUrlPresets.length) {
165
+ const custom = String(await prompter.ask('请输入 API 地址: ')).trim();
166
+ if (custom) env[agent.baseUrlKey] = custom;
167
+ }
168
+ const model = String(await prompter.ask(`默认模型 ${agent.modelKey}(可留空): `)).trim();
169
+ if (model) env[agent.modelKey] = model;
170
+
171
+ const defaultWork = deps.defaultWorkpath;
172
+ let hostPath = String(await prompter.ask(`工作目录(Agent 只能看到这里)[${defaultWork}]: `)).trim() || defaultWork;
173
+ if (!path.isAbsolute(hostPath)) {
174
+ log('工作目录必须是绝对路径,已退出,没有改动任何配置。');
175
+ return 1;
176
+ }
177
+ hostPath = path.resolve(hostPath);
178
+ fs.mkdirSync(hostPath, { recursive: true });
179
+ deps.validateHostPath(hostPath);
180
+
181
+ const password = await askPassword(prompter, log);
182
+
183
+ let mirrors = {};
184
+ if (/^y(es)?$/i.test(String(await prompter.ask('配置 apt / npm / pip 软件源吗?默认官方源 [y/N]: ')).trim())) {
185
+ mirrors = await askMirrors(prompter, log);
186
+ }
187
+
188
+ // 一次性构造并校验新配置,通过后原子写入;任何一步出错都不会留下半份配置
189
+ const configPath = deps.configPath;
190
+ const exists = fs.existsSync(configPath);
191
+ const snapshot = { exists, raw: exists ? fs.readFileSync(configPath, 'utf-8') : '' };
192
+ const parsed = exists && snapshot.raw.trim() ? JSON5.parse(snapshot.raw) : {};
193
+ const existingRun = parsed.runs && typeof parsed.runs === 'object' ? parsed.runs[agent.id] : undefined;
194
+ const profile = buildAgentRunProfile(agent.id, env, { existingRun, hostPath });
195
+ let nextRaw = buildAgentConfigRaw(baseRawOf(snapshot), agent.id, profile);
196
+ nextRaw = buildPasswordConfigRaw(nextRaw, password);
197
+ if (shouldWriteMirrors(parsed, mirrors)) nextRaw = buildMirrorsConfigRaw(nextRaw, normalizeMirrors(mirrors));
198
+ JSON5.parse(nextRaw);
199
+ writeConfigFileSecure(configPath, nextRaw);
200
+ log(`已保存到 ${configPath}(权限 0600):Agent ${agent.id}、工作目录 ${hostPath}、登录密码${Object.keys(mirrors).length > 0 ? '、软件源' : ''}。`);
201
+ } catch (error) {
202
+ if (error instanceof InputClosedError) {
203
+ log('输入已结束,没有改动任何配置。');
204
+ return 1;
205
+ }
206
+ log(`配置失败:${error.message}`);
207
+ return 1;
208
+ } finally {
209
+ prompter.close();
210
+ }
211
+ if (deps.afterSave) await deps.afterSave();
212
+ return 0;
213
+ }
214
+
215
+ module.exports = { runSetupCli, createPrompter, getManyoyoConfigPath };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // 向导写配置的“构造新文本”部分:Web 接口(/api/setup/*)与命令行 `manyoyo setup` 共用,保证两边写出同样的配置。
4
+ // 只负责在原文上做局部替换(保留注释与其它字段),读写文件与校验由调用方负责。
5
+
6
+ const { upsertValueByPath } = require('./json5-text-edit');
7
+
8
+ const EMPTY_CONFIG_RAW = '{\n}\n';
9
+
10
+ function baseRawOf(snapshot) {
11
+ return snapshot && snapshot.exists && String(snapshot.raw || '').trim() ? snapshot.raw : EMPTY_CONFIG_RAW;
12
+ }
13
+
14
+ function buildAgentConfigRaw(baseRaw, agent, profile) {
15
+ return upsertValueByPath(baseRaw, ['runs', agent], JSON.stringify(profile, null, 4));
16
+ }
17
+
18
+ function buildPasswordConfigRaw(baseRaw, password) {
19
+ return upsertValueByPath(baseRaw, ['serverPass'], JSON.stringify(password));
20
+ }
21
+
22
+ // 全部为官方默认且配置里本来没有 mirrors 时不需要写任何东西
23
+ function shouldWriteMirrors(parsedConfig, mirrors) {
24
+ const hasExisting = Object.prototype.hasOwnProperty.call(parsedConfig || {}, 'mirrors');
25
+ return hasExisting || Boolean(mirrors.apt || mirrors.npm || mirrors.pip);
26
+ }
27
+
28
+ function buildMirrorsConfigRaw(baseRaw, mirrors) {
29
+ return upsertValueByPath(baseRaw, ['mirrors'], JSON.stringify(mirrors, null, 4));
30
+ }
31
+
32
+ module.exports = { EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw };