@xcanwin/manyoyo 8.1.0 → 8.2.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 CHANGED
@@ -71,7 +71,7 @@ manyoyo run -y c # 在沙箱里以 YOLO 模式启动 Claude Code
71
71
 
72
72
  ## 安全须知
73
73
 
74
- MANYOYO 降低风险,但不是“绝对安全”:主要隔离手段是容器而不是虚拟机;`YOLO / SOLO` 仍可能执行危险命令;`sock` 模式会暴露宿主机 Docker socket;对外监听必须设强密码。详见[安全须知](https://xcanwin.github.io/manyoyo/reference/container-modes)。
74
+ MANYOYO 降低风险,但不是“绝对安全”:主要隔离手段是容器而不是虚拟机;`YOLO / SOLO` 仍可能执行危险命令;`sock` 模式会暴露宿主机 Docker socket;对外监听必须设强密码。详见[安全须知](https://xcanwin.github.io/manyoyo/guide/security)。
75
75
 
76
76
  ## 文档
77
77
 
package/bin/manyoyo.js CHANGED
@@ -20,6 +20,8 @@ const { readImportState, waitForImport } = require('../lib/offline-import');
20
20
  const { runPodmanCommand } = require('../lib/podman-passthrough');
21
21
  const { detectHeadless, parseLauncherArgs } = require('../lib/headless');
22
22
  const { runSetupCli, createPrompter } = require('../lib/setup-cli');
23
+ const { runPostInstall } = require('../lib/post-install');
24
+ const { listServeInstances, formatServeTable } = require('../lib/serve-instances');
23
25
  const { pruneDanglingImages: pruneDanglingImagesSafely } = require('../lib/image-prune');
24
26
  const { runUninstall, readPid, defaultIsManyoyoServe, defaultKill } = require('../lib/uninstall');
25
27
  const appUpdate = require('../lib/app-update');
@@ -895,7 +897,7 @@ async function updateOfflineInstall(mode, options, globalConfig) {
895
897
 
896
898
  // Podman / VM 磁盘有变化只提示(需要新的完整包)
897
899
  const runtimeHint = appUpdate.describeRuntimeChange(appUpdate.readInstalledRecord(os.homedir()), result.manifest.runtime);
898
- if (runtimeHint) console.log(`${YELLOW}ℹ️ ${runtimeHint}${NC}`);
900
+ if (runtimeHint && appUpdate.markRuntimeHintShown(os.homedir(), result.manifest.runtime)) console.log(`${YELLOW}ℹ️ ${runtimeHint}${NC}`);
899
901
 
900
902
  // 新版本要求的镜像不存在就拉取;仍用旧镜像的容器只列出来,不动
901
903
  const imageName = String(globalConfig.imageName || 'ghcr.io/xcanwin/manyoyo');
@@ -1274,7 +1276,7 @@ async function setupCommander() {
1274
1276
  .description(`MANYOYO - AI Agent CLI Sandbox
1275
1277
  https://github.com/xcanwin/manyoyo
1276
1278
 
1277
- 不带参数直接运行,打开网页界面(首次使用进入配置向导):
1279
+ 不带参数直接运行,会启动网页服务,打开网页界面(首次使用进入配置向导):
1278
1280
  ${MANYOYO_NAME} 自动判断有无图形界面
1279
1281
  ${MANYOYO_NAME} --headless 强制按无图形界面处理(不打开浏览器,用密码登录)
1280
1282
  ${MANYOYO_NAME} --gui 强制按有图形界面处理(自动打开浏览器)`)
@@ -1429,6 +1431,7 @@ https://github.com/xcanwin/manyoyo
1429
1431
  serveCommand.option('-d, --detach', '后台启动网页服务并立即返回');
1430
1432
  serveCommand.option('--stop', '停止后台网页服务;必须显式传入 listen');
1431
1433
  serveCommand.option('--restart', '重启后台网页服务;必须显式传入 listen');
1434
+ serveCommand.option('--list', '列出正在运行的网页服务(监听地址、PID、版本、启动命令)');
1432
1435
  serveCommand.action((listen, options) => {
1433
1436
  selectAction('serve', {
1434
1437
  ...options,
@@ -1458,6 +1461,12 @@ https://github.com/xcanwin/manyoyo
1458
1461
  const pluginPlaywrightCommand = pluginCommand.command('playwright').description('管理 playwright 插件服务');
1459
1462
  registerPlaywrightAliasCommands(pluginPlaywrightCommand);
1460
1463
 
1464
+ // 安装包装完后调用的隐藏入口(不在帮助里):按有头 / 无头决定下一步
1465
+ if (process.argv[2] === '--post-install') {
1466
+ await runPostInstallCommand(process.argv.slice(3));
1467
+ process.exit(0);
1468
+ }
1469
+
1461
1470
  // No args (or only --headless / --gui): start (or reuse) the local web app; open the browser already logged in unless headless
1462
1471
  const launcherArgs = parseLauncherArgs(process.argv.slice(2));
1463
1472
  if (launcherArgs) {
@@ -1489,6 +1498,10 @@ https://github.com/xcanwin/manyoyo
1489
1498
  const isServerMode = options.server !== undefined;
1490
1499
  const isServerStopMode = Boolean(selectedAction === 'serve' && options.stop);
1491
1500
  const isServerRestartMode = Boolean(selectedAction === 'serve' && options.restart);
1501
+ const isServerListMode = Boolean(selectedAction === 'serve' && options.list);
1502
+ if (isServerListMode && (isServerStopMode || isServerRestartMode || options.detach || options.server !== true)) {
1503
+ throw new Error('serve --list 不能与 listen、-d、--stop、--restart 同时使用');
1504
+ }
1492
1505
 
1493
1506
  if (isServerStopMode && isServerRestartMode) {
1494
1507
  throw new Error('serve --stop 与 --restart 不能同时使用');
@@ -1497,35 +1510,25 @@ https://github.com/xcanwin/manyoyo
1497
1510
  UPDATE_CHECK_ENABLED = config.updateCheck !== false;
1498
1511
  MIRRORS = normalizeMirrors(config.mirrors);
1499
1512
  const noDockerActions = new Set(['init', 'update', 'config-show', 'plugin', 'doctor', 'uninstall', 'podman', 'setup']);
1500
- if (isServerStopMode) {
1513
+ if (isServerStopMode || isServerListMode) {
1501
1514
  noDockerActions.add('serve');
1502
1515
  }
1503
1516
  if (!noDockerActions.has(selectedAction)) {
1504
1517
  await ensureDocker(config.containerRuntime, { deferHeal: selectedAction === 'serve' });
1505
1518
  }
1506
1519
 
1520
+ if (isServerListMode) {
1521
+ console.log(formatServeTable(listServeInstances()));
1522
+ process.exit(0);
1523
+ }
1524
+
1507
1525
  if (options.update) {
1508
1526
  await updateManyoyo(options, config);
1509
1527
  process.exit(0);
1510
1528
  }
1511
1529
 
1512
1530
  if (selectedAction === 'setup') {
1513
- const interactive = Boolean(process.stdin.isTTY);
1514
- const code = await runSetupCli({
1515
- isTTY: interactive,
1516
- prompter: interactive ? createPrompter() : null,
1517
- configPath: getManyoyoConfigPath(),
1518
- defaultWorkpath: path.join(os.homedir(), '.manyoyo', 'work'),
1519
- validateHostPath: value => validateHostPathOrThrow(value),
1520
- commandName: MANYOYO_NAME,
1521
- log: line => console.log(line),
1522
- // 新密码要让后台服务重新读配置:先停掉旧的,再按当前环境(有头 / 无头)启动
1523
- afterSave: async () => {
1524
- stopBackgroundApp({ quiet: true });
1525
- await runAppLauncher({ exit: false });
1526
- }
1527
- });
1528
- process.exit(code);
1531
+ process.exit(await runSetupCommand());
1529
1532
  }
1530
1533
 
1531
1534
  if (selectedAction === 'podman') {
@@ -2349,12 +2352,66 @@ async function handlePostExit(runtime, defaultCommand) {
2349
2352
  }
2350
2353
  }
2351
2354
 
2355
+ async function runSetupCommand() {
2356
+ const interactive = Boolean(process.stdin.isTTY);
2357
+ return runSetupCli({
2358
+ isTTY: interactive,
2359
+ prompter: interactive ? createPrompter() : null,
2360
+ configPath: getManyoyoConfigPath(),
2361
+ defaultWorkpath: path.join(os.homedir(), '.manyoyo', 'work'),
2362
+ validateHostPath: value => validateHostPathOrThrow(value),
2363
+ commandName: MANYOYO_NAME,
2364
+ log: line => console.log(line),
2365
+ // 新密码要让后台服务重新读配置:先停掉旧的,再按当前环境(有头 / 无头)启动
2366
+ afterSave: async () => {
2367
+ stopBackgroundApp({ quiet: true });
2368
+ await runAppLauncher({ exit: false });
2369
+ }
2370
+ });
2371
+ }
2372
+
2373
+ async function runPostInstallCommand(args) {
2374
+ const launcher = parseLauncherArgs(args) || { force: null };
2375
+ const { headless } = detectHeadless({ force: launcher.force });
2376
+ const interactive = Boolean(process.stdin.isTTY);
2377
+ let prompter = null; // 需要提问时才创建(有图形界面、没有终端都不需要,避免多余的 readline 占着 stdin)
2378
+ try {
2379
+ await runPostInstall({
2380
+ headless,
2381
+ interactive,
2382
+ currentVersion: BIN_VERSION,
2383
+ ownRoot: path.join(os.homedir(), '.manyoyo'),
2384
+ commandName: MANYOYO_NAME,
2385
+ log: line => console.log(line),
2386
+ ask: text => {
2387
+ if (!prompter) prompter = createPrompter();
2388
+ return prompter.ask(text);
2389
+ },
2390
+ // 升级安装时先停掉旧版留下的本机服务,避免“复用”到旧代码
2391
+ startApp: () => {
2392
+ stopBackgroundApp({ quiet: true });
2393
+ return runAppLauncher({ force: launcher.force, exit: false });
2394
+ },
2395
+ // 同一个 stdin 不能同时挂两个提示器:选完之后先关掉这个,再交给 setup 自己的
2396
+ runSetup: async () => {
2397
+ if (prompter) prompter.close();
2398
+ return runSetupCommand();
2399
+ },
2400
+ listServes: () => listServeInstances()
2401
+ });
2402
+ } finally {
2403
+ if (prompter) prompter.close();
2404
+ }
2405
+ }
2406
+
2352
2407
  async function runAppLauncher({ force = null, exit = true } = {}) {
2353
2408
  try {
2354
2409
  const { headless } = detectHeadless({ force });
2410
+ const launcherConfig = readManyoyoConfig().config;
2355
2411
  await launchApp({
2356
2412
  headless,
2357
- authUser: String(readManyoyoConfig().config.serverUser || '').trim() || 'admin',
2413
+ authUser: String(launcherConfig.serverUser || '').trim() || 'admin',
2414
+ hasPassword: Boolean(String(launcherConfig.serverPass || process.env.MANYOYO_SERVER_PASS || '').trim()),
2358
2415
  statePath: getAppStatePath(),
2359
2416
  isProcessRunning,
2360
2417
  spawnServe: port => {
@@ -190,9 +190,11 @@ async function launchApp(deps) {
190
190
  if (deps.headless) {
191
191
  // 无头环境没有浏览器:不签发一次性令牌(用户要先建立端口转发,60 秒不够),用密码登录
192
192
  const commandName = deps.commandName || 'manyoyo';
193
- log(`${reused ? '已复用运行中的服务' : '已启动本机服务'}(无头环境,不打开浏览器): ${baseUrl}`);
194
- log(` 在你自己的电脑上转发端口: ssh -L ${state.port}:127.0.0.1:${state.port} <用户名>@<这台机器的地址>,然后浏览器访问 ${baseUrl}`);
195
- log(` 登录用户名: ${deps.authUser || 'admin'};密码是你用 ${commandName} setup 设置的那个(还没设置请先运行 ${commandName} setup)`);
193
+ log(`${reused ? '已复用运行中的网页版' : '已启动网页版'}(只监听本机 ${APP_HOST}:${state.port})`);
194
+ log(` 在你自己的电脑上执行: ssh -L ${state.port}:127.0.0.1:${state.port} <用户名>@<这台机器的地址>`);
195
+ log(` 然后浏览器打开: ${baseUrl}`);
196
+ if (deps.hasPassword) log(` 登录用户名 ${deps.authUser || 'admin'};密码是你用 ${commandName} setup 设置的那个`);
197
+ else log(` 登录用户名 ${deps.authUser || 'admin'};还没有设置登录密码,请先运行 ${commandName} setup`);
196
198
  log(` 关闭服务: ${commandName} serve ${APP_HOST}:${state.port} --stop`);
197
199
  return { reused, opened: false, headless: true, baseUrl, loginUrl: '', pid: state.pid };
198
200
  }
package/lib/app-update.js CHANGED
@@ -275,6 +275,27 @@ function describeRuntimeChange(installed, remoteRuntime) {
275
275
  return `新版本捆绑的运行环境有变化(${parts.join(',')})。本次只升级了 manyoyo 本体;要换 Podman / 虚拟机,请下载新的完整安装包重新安装。`;
276
276
  }
277
277
 
278
+ /**
279
+ * 同一份新运行环境的提示只显示一次(每次 update 都重复提示只是噪音):
280
+ * 记下已提示过的 runtime(~/.manyoyo/.install/runtime-hint.json),返回这次是否应该显示。
281
+ */
282
+ function markRuntimeHintShown(homeDir, runtime) {
283
+ const file = path.join(homeDir, '.manyoyo', '.install', 'runtime-hint.json');
284
+ const key = JSON.stringify({ podmanVersion: runtime && runtime.podmanVersion || '', vmDiskSha256: runtime && runtime.vmDiskSha256 || '' });
285
+ try {
286
+ if (fs.readFileSync(file, 'utf-8').trim() === key) return false;
287
+ } catch (error) {
288
+ // 没有记录或记录损坏:当作没提示过
289
+ }
290
+ try {
291
+ fs.mkdirSync(path.dirname(file), { recursive: true });
292
+ fs.writeFileSync(file, `${key}\n`);
293
+ } catch (error) {
294
+ // 记录写不进去不影响升级,最多下次再提示一次
295
+ }
296
+ return true;
297
+ }
298
+
278
299
  function readInstalledRecord(homeDir) {
279
300
  try {
280
301
  return JSON.parse(fs.readFileSync(path.join(homeDir, '.manyoyo', '.install', 'installed.json'), 'utf-8'));
@@ -316,6 +337,7 @@ module.exports = {
316
337
  rollbackApp,
317
338
  currentVersion,
318
339
  describeRuntimeChange,
340
+ markRuntimeHintShown,
319
341
  readInstalledRecord,
320
342
  findOutdatedContainers,
321
343
  fetchText,
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ // 安装完成后的“下一步”:由安装包调用 `manyoyo --post-install [--headless|--gui]`(隐藏入口,不在帮助里)。
4
+ // 有图形界面 直接启动网页服务并打开浏览器
5
+ // 无头 + 有终端 问一次:1 在终端里配置(manyoyo setup)/ 2 启动网页版(SSH 端口转发)/ 3 先不配置
6
+ // 无头 + 没有终端 只安装,不启动任何服务,打印下一步(脚本、CI、ssh host '…' 的场景)
7
+ // 另外会提示:PATH 里还有另一份 manyoyo、还在运行旧版代码的 serve。所有 IO 注入,便于测试。
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { inspectScript, findOutdatedInstances, restartCommand, sourceLabel } = require('./serve-instances');
12
+
13
+ /**
14
+ * PATH 里除 ownRoot(~/.manyoyo)之外的其他 manyoyo 可执行文件,按真实路径去重。
15
+ */
16
+ function findOtherManyoyo({ pathEnv = process.env.PATH || '', ownRoot, currentVersion = '' } = {}) {
17
+ const seen = new Set();
18
+ const result = [];
19
+ // home 可能经过符号链接(如 /home → /var/home):realpath 后的路径要和解析后的 ownRoot 比
20
+ let realOwnRoot = ownRoot || '';
21
+ try {
22
+ if (ownRoot) realOwnRoot = fs.realpathSync(ownRoot);
23
+ } catch (error) {
24
+ // 目录不存在:按原样比较
25
+ }
26
+ for (const dir of String(pathEnv).split(path.delimiter).filter(Boolean)) {
27
+ const candidate = path.join(dir, 'manyoyo');
28
+ let real;
29
+ try {
30
+ fs.accessSync(candidate, fs.constants.X_OK);
31
+ real = fs.realpathSync(candidate);
32
+ } catch (error) {
33
+ continue;
34
+ }
35
+ if (seen.has(real)) continue;
36
+ seen.add(real);
37
+ if (ownRoot && (real.startsWith(`${ownRoot}${path.sep}`) || real.startsWith(`${realOwnRoot}${path.sep}`))) continue;
38
+ const info = inspectScript(real);
39
+ if (info.version && info.version === currentVersion && info.source === 'offline') continue;
40
+ result.push({ path: candidate, version: info.version, source: info.source });
41
+ }
42
+ return result;
43
+ }
44
+
45
+ function otherManyoyoNotice(others, currentVersion) {
46
+ if (others.length === 0) return [];
47
+ const lines = ['', '提示:这台机器上还有另一份 manyoyo'];
48
+ for (const item of others) {
49
+ const detail = [item.version || '版本未知', sourceLabel(item.source)].join(',');
50
+ lines.push(` ${item.path}(${detail})`);
51
+ }
52
+ lines.push(` 新开终端后,输入 manyoyo 会优先使用本次的 ${currentVersion};另一份不会被改动。`);
53
+ return lines;
54
+ }
55
+
56
+ function outdatedServeNotice(instances, currentVersion) {
57
+ const outdated = findOutdatedInstances(instances, currentVersion);
58
+ if (outdated.length === 0) return [];
59
+ const lines = ['', '提示:有 serve 还在运行旧版代码,需要重启才会换成新版'];
60
+ for (const item of outdated) {
61
+ lines.push(` ${item.listen}(pid ${item.pid},版本 ${item.version || '未知'},${sourceLabel(item.source)})`);
62
+ }
63
+ const hasMaskedPassword = outdated.some(item => item.args.includes('******'));
64
+ lines.push(` 重启命令${hasMaskedPassword ? '(把 ****** 换成你原来的密码)' : ''}:`);
65
+ for (const item of outdated) lines.push(` ${restartCommand(item)}`);
66
+ return lines;
67
+ }
68
+
69
+ const CHOICES = [
70
+ '在终端里配置(推荐)',
71
+ '启动网页版,用你自己电脑的浏览器访问(需要 SSH 端口转发)',
72
+ '先不配置,以后自己运行 manyoyo setup'
73
+ ];
74
+
75
+ async function askChoice(ask, log) {
76
+ log('');
77
+ log('这台机器没有图形界面,怎么完成首次配置?');
78
+ CHOICES.forEach((text, index) => log(` ${index + 1}) ${text}`));
79
+ for (;;) {
80
+ let answer;
81
+ try {
82
+ answer = String(await ask('请选择 [默认选1]: ')).trim();
83
+ } catch (error) {
84
+ return 3; // 输入被中断(Ctrl-D / stdin 关闭):按“先不配置”处理
85
+ }
86
+ if (answer === '') return 1;
87
+ if (['1', '2', '3'].includes(answer)) return Number(answer);
88
+ log('请输入 1、2 或 3。');
89
+ }
90
+ }
91
+
92
+ function installedOnlyHint(log, name) {
93
+ log('');
94
+ log('已安装,没有启动任何服务。之后:');
95
+ log(` ${name} setup 在终端里配置`);
96
+ log(` ${name} 启动网页版(无图形界面时会告诉你怎么转发端口)`);
97
+ }
98
+
99
+ /**
100
+ * @param {object} deps
101
+ * headless, interactive, currentVersion, ownRoot, commandName
102
+ * ask(prompt) → Promise<string>、log(line)
103
+ * startApp() → Promise 启动(或复用)网页服务;有头时打开浏览器
104
+ * runSetup() → Promise<number> manyoyo setup 的退出码
105
+ * findOthers() → [] / listServes() → [](默认读真实环境)
106
+ */
107
+ async function runPostInstall(deps) {
108
+ const { headless, interactive, currentVersion, log, ask } = deps;
109
+ const name = deps.commandName || 'manyoyo';
110
+ const others = deps.findOthers ? deps.findOthers() : findOtherManyoyo({ ownRoot: deps.ownRoot, currentVersion });
111
+ otherManyoyoNotice(others, currentVersion).forEach(line => log(line));
112
+
113
+ if (!headless) {
114
+ await deps.startApp();
115
+ } else if (!interactive) {
116
+ installedOnlyHint(log, name);
117
+ } else {
118
+ const choice = await askChoice(ask, log);
119
+ if (choice === 1) {
120
+ log('');
121
+ const code = await deps.runSetup();
122
+ if (code === 0) {
123
+ log('');
124
+ log('✓ 配置完成。常用命令:');
125
+ log(` ${name} run -y c 在沙箱里启动 Claude Code`);
126
+ log(` ${name} 启动网页服务,打开网页版(无图形界面时会告诉你怎么转发端口)`);
127
+ log(` ${name} serve --list 查看正在运行的 serve`);
128
+ } else {
129
+ log(`配置没有完成,之后运行 ${name} setup 重试。`);
130
+ }
131
+ } else if (choice === 2) {
132
+ await deps.startApp();
133
+ } else {
134
+ installedOnlyHint(log, name);
135
+ }
136
+ }
137
+
138
+ const serves = deps.listServes ? deps.listServes() : [];
139
+ outdatedServeNotice(serves, currentVersion).forEach(line => log(line));
140
+ return 0;
141
+ }
142
+
143
+ module.exports = { runPostInstall, findOtherManyoyo, otherManyoyoNotice, outdatedServeNotice };
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // 正在运行的 manyoyo serve 实例:靠 ~/.manyoyo/run/serve/<监听地址>.pid 记录(7.x 起就是这个格式,所以也能认出旧版实例)。
4
+ // `manyoyo serve --list` 与安装器装完后的“旧版 serve 还在运行”提示共用这里。
5
+ // 版本与来源从进程命令行里的脚本路径推断(脚本旁边的 package.json;npm 全局安装或 ~/.manyoyo/app/<版本>/)。
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ const DEFAULT_LISTEN = '127.0.0.1:3000';
13
+ const SECRET_FLAGS = new Set(['-P', '--pass']);
14
+ const SECRET_ENV_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASS|AUTH|CREDENTIAL)/i;
15
+
16
+ // Linux 优先读 /proc/<pid>/cmdline(NUL 分隔,路径里有空格也不会被拆开);否则退回 ps(macOS)
17
+ function defaultReadArgs(pid) {
18
+ try {
19
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
20
+ const tokens = raw.split('\0').filter(Boolean);
21
+ if (tokens.length > 0) return tokens;
22
+ } catch (error) {
23
+ // 没有 /proc:用 ps
24
+ }
25
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf-8' });
26
+ return result.status === 0 ? String(result.stdout || '').trim() : '';
27
+ }
28
+
29
+ function defaultIsRunning(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch (error) {
34
+ return error && error.code === 'EPERM';
35
+ }
36
+ }
37
+
38
+ /** 密码不出现在任何输出里:-P <值> / --pass <值> / --pass=<值> 都遮成 ****** */
39
+ function maskSecretArgs(tokens) {
40
+ const result = [];
41
+ const maskEnv = value => value.replace(/^([^=]+)=.*$/, (all, name) => (SECRET_ENV_NAME.test(name) ? `${name}=******` : all));
42
+ for (let i = 0; i < tokens.length; i += 1) {
43
+ const token = tokens[i];
44
+ if ((token === '-e' || token === '--env') && i + 1 < tokens.length) {
45
+ result.push(token, maskEnv(tokens[i + 1]));
46
+ i += 1;
47
+ } else if (/^--env=/.test(token)) {
48
+ result.push(`--env=${maskEnv(token.slice(6))}`);
49
+ } else if (/^-P.+/.test(token)) {
50
+ result.push('-P', '******'); // 粘连写法 -Psecret
51
+ } else if (SECRET_FLAGS.has(token)) {
52
+ result.push(token);
53
+ if (i + 1 < tokens.length) {
54
+ result.push('******');
55
+ i += 1;
56
+ }
57
+ } else if (/^--pass=/.test(token)) {
58
+ result.push('--pass=******');
59
+ } else {
60
+ result.push(token);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function listenFromFileName(name) {
67
+ const base = name.replace(/\.pid$/, '');
68
+ const index = base.lastIndexOf('_');
69
+ return index > 0 ? `${base.slice(0, index)}:${base.slice(index + 1)}` : DEFAULT_LISTEN;
70
+ }
71
+
72
+ function inspectScript(scriptPath, readFile = fs.readFileSync, realpath = fs.realpathSync) {
73
+ let real = scriptPath;
74
+ try {
75
+ real = realpath(scriptPath);
76
+ } catch (error) {
77
+ // 路径已不存在:按原样判断
78
+ }
79
+ let version = '';
80
+ try {
81
+ version = String(JSON.parse(readFile(path.join(path.dirname(real), '..', 'package.json'), 'utf-8')).version || '');
82
+ } catch (error) {
83
+ // 读不到就是“版本未知”
84
+ }
85
+ const normalized = real.split(path.sep).join('/');
86
+ let source = 'unknown';
87
+ if (normalized.includes('/node_modules/@xcanwin/manyoyo/')) source = 'npm';
88
+ else if (normalized.includes('/.manyoyo/app/')) source = 'offline';
89
+ return { version, source };
90
+ }
91
+
92
+ /** 把 `node /…/manyoyo.js serve 0.0.0.0:3000 -U admin -P x` 拆成脚本与 serve 之后的参数 */
93
+ function parseServeCommand(commandLine) {
94
+ const tokens = Array.isArray(commandLine) ? commandLine : String(commandLine || '').trim().split(/\s+/).filter(Boolean);
95
+ const serveIndex = tokens.indexOf('serve');
96
+ if (serveIndex < 1) return null;
97
+ const script = tokens.slice(0, serveIndex).reverse().find(token => /manyoyo(\.js)?$|\/my$|\.js$/.test(token)) || tokens[serveIndex - 1];
98
+ const args = tokens.slice(serveIndex + 1);
99
+ // 监听地址形如 host:port 或 [ipv6]:port;不能取“第一个非选项参数”,否则 -U admin 的 admin 会被当成地址
100
+ const listen = args.find(token => /^(\[[^\]]+\]|[A-Za-z0-9.-]+):\d+$/.test(token)) || '';
101
+ return { script, args, listen };
102
+ }
103
+
104
+ function listServeInstances(options = {}) {
105
+ const homeDir = options.homeDir || os.homedir();
106
+ const readArgs = options.readArgs || defaultReadArgs;
107
+ const isRunning = options.isRunning || defaultIsRunning;
108
+ const dir = path.join(homeDir, '.manyoyo', 'run', 'serve');
109
+ let names = [];
110
+ try {
111
+ names = fs.readdirSync(dir).filter(name => name.endsWith('.pid')).sort();
112
+ } catch (error) {
113
+ return [];
114
+ }
115
+ const instances = [];
116
+ for (const name of names) {
117
+ let pid = 0;
118
+ try {
119
+ const text = fs.readFileSync(path.join(dir, name), 'utf-8').trim();
120
+ pid = /^\d+$/.test(text) ? Number(text) : 0;
121
+ } catch (error) {
122
+ pid = 0;
123
+ }
124
+ if (!pid || !isRunning(pid)) continue;
125
+ const parsed = parseServeCommand(readArgs(pid));
126
+ // 命令行不是 manyoyo serve:pid 文件残留且 pid 已被别的进程复用,不能算 serve 实例
127
+ if (!parsed) continue;
128
+ const info = inspectScript(parsed.script);
129
+ const args = parsed.args;
130
+ instances.push({
131
+ pid,
132
+ // 命令行里写了监听地址就用它;没写(用默认或配置)时 pid 文件名就是实际监听地址(host_port)
133
+ listen: parsed.listen || listenFromFileName(name),
134
+ version: info.version,
135
+ source: info.source,
136
+ args: maskSecretArgs(args),
137
+ command: `my serve ${maskSecretArgs(args).join(' ')}`.trim()
138
+ });
139
+ }
140
+ return instances;
141
+ }
142
+
143
+ function sourceLabel(source) {
144
+ return { npm: '由全局 Node 的 npm 安装', offline: '安装包安装', unknown: '来源未知' }[source] || '来源未知';
145
+ }
146
+
147
+ /** 表格:监听地址 / PID / 版本 / 启动命令 */
148
+ function formatServeTable(instances) {
149
+ if (instances.length === 0) return '没有正在运行的 serve。';
150
+ const rows = instances.map(item => [item.listen, String(item.pid), item.version || '未知', item.command]);
151
+ const header = ['监听地址', 'PID', '版本', '启动命令'];
152
+ // 终端里中日韩字符占两列,按显示宽度对齐
153
+ const width = text => [...text].reduce((sum, char) => sum + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60]/.test(char) ? 2 : 1), 0);
154
+ const widths = header.map((_, column) => Math.max(...[header, ...rows].map(row => width(row[column]))));
155
+ const pad = (text, size) => text + ' '.repeat(size - width(text) + 2);
156
+ return [header, ...rows].map(row => row.map((cell, column) => (column === 3 ? cell : pad(cell, widths[column]))).join('').trimEnd()).join('\n');
157
+ }
158
+
159
+ /** 与当前版本不同的实例(旧版代码还在跑) */
160
+ function findOutdatedInstances(instances, currentVersion) {
161
+ return instances.filter(item => item.version !== currentVersion);
162
+ }
163
+
164
+ /** 重启成新版的命令:沿用原参数(密码仍是 ******,需要换回原密码),加 -d --restart */
165
+ function restartCommand(instance) {
166
+ const args = instance.args.filter(token => token !== '-d' && token !== '--detach' && token !== '--restart');
167
+ // --restart 必须显式带监听地址:命令行里没写(用了默认或配置里的)就补上实际监听地址
168
+ if (!args.includes(instance.listen)) args.unshift(instance.listen);
169
+ return `my serve ${args.join(' ')} -d --restart`.replace(/\s+/g, ' ');
170
+ }
171
+
172
+ module.exports = { listServeInstances, formatServeTable, findOutdatedInstances, restartCommand, sourceLabel, maskSecretArgs, parseServeCommand, inspectScript };
package/lib/setup-cli.js CHANGED
@@ -68,7 +68,7 @@ async function chooseNumber(prompter, log, title, items, { defaultIndex = 0, all
68
68
  items.forEach((item, index) => log(` ${index + 1}) ${item}`));
69
69
  if (allowSkip) log(' 0) 跳过 / 官方默认');
70
70
  for (;;) {
71
- const answer = String(await prompter.ask(`请选择${allowSkip ? '' : ` [${defaultIndex + 1}]`}: `)).trim();
71
+ const answer = String(await prompter.ask(allowSkip ? '请选择(回车 = 跳过): ' : `请选择 [默认选${defaultIndex + 1}]: `)).trim();
72
72
  if (answer === '' && !allowSkip) return defaultIndex;
73
73
  if (allowSkip && (answer === '' || answer === '0')) return -1;
74
74
  const index = Number(answer) - 1;
@@ -137,10 +137,14 @@ async function runSetupCli(deps) {
137
137
  }
138
138
  const prompter = deps.prompter;
139
139
  try {
140
+ // 步骤与网页向导一致:1 选择 Agent → 2 接入方式(Base URL → Key → 模型)→ 3 工作目录 → 4 保存并开始(登录密码)
140
141
  const agents = listSetupAgents();
141
- const agentIndex = await chooseNumber(prompter, log, '选择要配置的 Agent:', agents.map(item => `${item.label}(${item.id})`));
142
+ log('\n第 1 / 4 步 · 选择 Agent');
143
+ const agentIndex = await chooseNumber(prompter, log, '选择你想在沙箱里运行的 Agent:', agents.map(item => `${item.label}(${item.id})`));
142
144
  const agent = agents[agentIndex];
143
145
 
146
+ log('\n第 2 / 4 步 · 接入方式');
147
+ const modeIndex = await chooseNumber(prompter, log, '访问模型服务的方式:', ['官方 API Key', '兼容服务(自定义 Base URL)']);
144
148
  const env = {};
145
149
  let keyName = agent.requiredAnyOf[0];
146
150
  if (agent.requiredAnyOf.length > 1) {
@@ -150,26 +154,32 @@ async function runSetupCli(deps) {
150
154
  }));
151
155
  keyName = agent.requiredAnyOf[keyIndex];
152
156
  }
153
- const key = String(await prompter.askSecret(`${keyName}(输入不回显): `)).trim();
157
+ if (modeIndex === 1) {
158
+ const baseUrlItems = [...agent.baseUrlPresets.map(item => `${item.label} ${item.value}`), '自定义地址'];
159
+ const baseIndex = await chooseNumber(prompter, log, 'Base URL:', baseUrlItems);
160
+ if (baseIndex < agent.baseUrlPresets.length) {
161
+ env[agent.baseUrlKey] = agent.baseUrlPresets[baseIndex].value;
162
+ } else {
163
+ const custom = String(await prompter.ask('请输入 Base URL: ')).trim();
164
+ if (!custom) {
165
+ log('兼容服务必须填写 Base URL,已退出,没有改动任何配置。');
166
+ return 1;
167
+ }
168
+ env[agent.baseUrlKey] = custom;
169
+ }
170
+ }
171
+ const key = String(await prompter.askSecret(`API Key(${keyName},输入不回显): `)).trim();
154
172
  if (!key) {
155
173
  log('凭据不能为空,已退出,没有改动任何配置。');
156
174
  return 1;
157
175
  }
158
176
  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();
177
+ const model = String(await prompter.ask(`模型(${agent.modelKey},可选,回车跳过): `)).trim();
169
178
  if (model) env[agent.modelKey] = model;
170
179
 
180
+ log('\n第 3 / 4 步 · 工作目录');
171
181
  const defaultWork = deps.defaultWorkpath;
172
- let hostPath = String(await prompter.ask(`工作目录(Agent 只能看到这里)[${defaultWork}]: `)).trim() || defaultWork;
182
+ let hostPath = String(await prompter.ask(`工作目录(Agent 只能看到这里)[默认 ${defaultWork}]: `)).trim() || defaultWork;
173
183
  if (!path.isAbsolute(hostPath)) {
174
184
  log('工作目录必须是绝对路径,已退出,没有改动任何配置。');
175
185
  return 1;
@@ -178,10 +188,11 @@ async function runSetupCli(deps) {
178
188
  fs.mkdirSync(hostPath, { recursive: true });
179
189
  deps.validateHostPath(hostPath);
180
190
 
191
+ log('\n第 4 / 4 步 · 保存并开始');
181
192
  const password = await askPassword(prompter, log);
182
193
 
183
194
  let mirrors = {};
184
- if (/^y(es)?$/i.test(String(await prompter.ask('配置 apt / npm / pip 软件源吗?默认官方源 [y/N]: ')).trim())) {
195
+ if (/^y(es)?$/i.test(String(await prompter.ask('配置 apt / npm / pip 软件源吗?默认官方源 [默认选否 y/N]: ')).trim())) {
185
196
  mirrors = await askMirrors(prompter, log);
186
197
  }
187
198