@xcanwin/manyoyo 8.2.0 → 8.3.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.en.md ADDED
@@ -0,0 +1,88 @@
1
+ <p align="center">
2
+ <img src="./assets/manyoyo-logo-09-cyberpunk-terminal.svg" alt="MANYOYO logo" width="560" />
3
+ </p>
4
+
5
+ # <p align="center"><a href="https://github.com/xcanwin/manyoyo">MANYOYO</a></p>
6
+ <p align="center"><b>MANYOYO – open-source sandbox for running AI coding agents (Claude Code / Codex / Gemini) safely in Docker/Podman</b></p>
7
+ <p align="center">Run Claude Code / Codex / Gemini / OpenCode in YOLO mode inside a container: let the AI go wild without hurting your computer.</p>
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/@xcanwin/manyoyo"><img alt="npm" src="https://img.shields.io/npm/v/@xcanwin/manyoyo?style=flat-square" /></a>
10
+ <a href="https://github.com/xcanwin/manyoyo/actions/workflows/npm-publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/xcanwin/manyoyo/npm-publish.yml?style=flat-square" /></a>
11
+ <a href="https://github.com/xcanwin/manyoyo/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="README.md">中文</a> |
16
+ <a href="README.en.md"><b>English</b></a>
17
+ </p>
18
+ <p align="center">
19
+ Docs: <a href="https://xcanwin.github.io/manyoyo/en/">https://xcanwin.github.io/manyoyo/en/</a>
20
+ </p>
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ ### macOS / Linux (recommended)
27
+
28
+ ```bash
29
+ curl -fsSL https://github.com/xcanwin/manyoyo/raw/main/scripts/install.sh | sh
30
+ ```
31
+
32
+ When it finishes, follow the on-screen prompts to finish setup. macOS needs nothing installed first. Linux: if the container runtime is missing, the installer explains why and, with your consent, installs it with sudo.
33
+
34
+ ### Already have Node.js and Docker/Podman
35
+
36
+ ```bash
37
+ npm install -g @xcanwin/manyoyo
38
+ manyoyo
39
+ ```
40
+
41
+ ## Daily use
42
+
43
+ ```bash
44
+ manyoyo # open the web UI
45
+ manyoyo update # upgrade
46
+ manyoyo uninstall # uninstall (config and data are kept by default)
47
+ ```
48
+
49
+ ## Command line (optional)
50
+
51
+ ```bash
52
+ manyoyo init all # import the agent configs already on this machine
53
+ manyoyo run -y c # start Claude Code in YOLO mode inside the sandbox
54
+ ```
55
+
56
+ The default image `ghcr.io/xcanwin/manyoyo` is pulled automatically on first use; run `manyoyo build --iv 2.1.0-common` only if you need a custom image. More commands: [CLI reference](https://xcanwin.github.io/manyoyo/en/reference/cli-options).
57
+
58
+ ## What it does
59
+
60
+ - **Multiple agents**: `claude`, `codex`, `gemini`, `opencode` behind one entry point
61
+ - **Container isolation**: Docker / Podman; the agent only sees the work directory you give it
62
+ - **Web UI**: chat, browse files and open a terminal in the browser, phone friendly
63
+ - **One config**: run profiles, environment variables, mounts and image arguments in `~/.manyoyo/manyoyo.json`
64
+ - **One command** to install, upgrade and uninstall
65
+
66
+ | | Running the agent CLI directly | MANYOYO |
67
+ | --- | --- | --- |
68
+ | Host exposure | High | Lower |
69
+ | Run boundary | Scattered | Centralised in the container and config |
70
+ | Reproducible environment | Weak | Strong (image + config) |
71
+ | High-risk mode warnings | Left to the tool | Explicit YOLO / SOLO / sock warnings |
72
+
73
+ ## Security notes
74
+
75
+ MANYOYO reduces risk but is not "absolutely safe": the main isolation is a container, not a virtual machine; `YOLO / SOLO` can still run dangerous commands; `sock` mode exposes the host Docker socket; set a strong password before listening on a public address. See [Security notes](https://xcanwin.github.io/manyoyo/en/guide/security).
76
+
77
+ ## Docs
78
+
79
+ - 中文: <https://xcanwin.github.io/manyoyo/>
80
+ - English: <https://xcanwin.github.io/manyoyo/en/> ([Install](https://xcanwin.github.io/manyoyo/en/guide/quick-start), [CLI reference](https://xcanwin.github.io/manyoyo/en/reference/cli-options), [Configuration](https://xcanwin.github.io/manyoyo/en/configuration/), [FAQ](https://xcanwin.github.io/manyoyo/en/troubleshooting/))
81
+
82
+ ## License
83
+
84
+ MIT
85
+
86
+ ## Contributing
87
+
88
+ [Issues](https://github.com/xcanwin/manyoyo/issues) and pull requests are welcome.
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  </p>
4
4
 
5
5
  # <p align="center"><a href="https://github.com/xcanwin/manyoyo">MANYOYO(慢悠悠)</a></p>
6
- <p align="center">让 Claude Code / Codex / Gemini / OpenCode 在容器里放心跑 YOLO 模式:AI 随便折腾,伤不到你的电脑。</p>
6
+ <p align="center"><b>MANYOYO – open-source sandbox for running AI coding agents (Claude Code / Codex / Gemini) safely in Docker/Podman</b></p>
7
+ <p align="center">开源 AI Agent 沙箱:让 Claude Code / Codex / Gemini / OpenCode 在容器里放心跑 YOLO 模式,AI 随便折腾,伤不到你的电脑。</p>
7
8
  <p align="center">
8
9
  <a href="https://www.npmjs.com/package/@xcanwin/manyoyo"><img alt="npm" src="https://img.shields.io/npm/v/@xcanwin/manyoyo?style=flat-square" /></a>
9
10
  <a href="https://github.com/xcanwin/manyoyo/actions/workflows/npm-publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/xcanwin/manyoyo/npm-publish.yml?style=flat-square" /></a>
@@ -12,7 +13,7 @@
12
13
 
13
14
  <p align="center">
14
15
  <a href="README.md"><b>中文</b></a> |
15
- <a href="https://xcanwin.github.io/manyoyo/en/">English</a>
16
+ <a href="README.en.md">English</a>
16
17
  </p>
17
18
  <p align="center">
18
19
  文档:<a href="https://xcanwin.github.io/manyoyo/">https://xcanwin.github.io/manyoyo/</a>
@@ -28,7 +29,7 @@
28
29
  curl -fsSL https://github.com/xcanwin/manyoyo/raw/main/scripts/install.sh | sh
29
30
  ```
30
31
 
31
- 装完浏览器自动打开,按向导选 Agent、填 Key 就能开始对话。macOS 不需要预装任何东西;Linux 需要已有 Docker 或 Podman。
32
+ 装完按屏幕提示完成配置。macOS 不需要预装任何东西。Linux:缺少容器环境时,安装器会说明原因,征得你同意后用 sudo 自动安装。
32
33
 
33
34
  ### 已有 Node.js 和 Docker/Podman
34
35
 
package/bin/manyoyo.js CHANGED
@@ -9,7 +9,7 @@ const net = require('net');
9
9
  const readline = require('readline');
10
10
  const { Command, Help } = require('commander');
11
11
  const { startWebServer } = require('../lib/web/server');
12
- const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/container-run');
12
+ const { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile } = require('../lib/container-run');
13
13
  const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
14
14
  const { resolveUpdateImageVersion } = require('../lib/image-version-policy');
15
15
  const { selectContainerRuntime, mergeRuntimeEnv } = require('../lib/container-runtime');
@@ -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');
@@ -45,6 +47,7 @@ const { applyAptMirror } = require('../lib/mirrors');
45
47
  const {
46
48
  sanitizeSensitiveData,
47
49
  sanitizeServeLogText,
50
+ redactCommandArgs,
48
51
  formatServeLogValue,
49
52
  getServeProcessSnapshot
50
53
  } = require('../lib/serve-log');
@@ -726,6 +729,18 @@ async function ensureRunImage(runtime) {
726
729
  });
727
730
  }
728
731
 
732
+ // 把 run 失败的 stderr 尾部(已打码)与可执行的修复提示拼进错误信息,而不是只剩一行 Command failed
733
+ function explainCreateFailure(err) {
734
+ const raw = getCommandFailureText(err);
735
+ const tail = sanitizeServeLogText(String(err && err.stderr || '')).trim().split('\n').slice(-8).join('\n');
736
+ const info = describeError(raw, { command: DOCKER_CMD });
737
+ const parts = [err.message];
738
+ if (tail) parts.push(tail);
739
+ if (info && info.code === 'ROOTLESS_NETWORK_MISSING') parts.push(`提示: ${info.reason}\n${info.action}`);
740
+ err.message = parts.join('\n');
741
+ return err;
742
+ }
743
+
729
744
  function showImagePullHint(err) {
730
745
  const info = describeError(getCommandFailureText(err), { imageRef: `${IMAGE_NAME}:${IMAGE_VERSION}` });
731
746
  if (!info || !['IMAGE_PULL_FAILED', 'IMAGE_NOT_FOUND'].includes(info.code)) {
@@ -751,7 +766,7 @@ function runCmd(cmd, args, options = {}) {
751
766
  if (options.ignoreError) {
752
767
  return result.stdout || '';
753
768
  }
754
- const err = new Error(`Command failed: ${cmd} ${args.join(' ')}`);
769
+ const err = new Error(`Command failed: ${cmd} ${redactCommandArgs(args).join(' ')}`);
755
770
  err.stdout = result.stdout;
756
771
  err.stderr = result.stderr;
757
772
  err.status = result.status;
@@ -1274,7 +1289,7 @@ async function setupCommander() {
1274
1289
  .description(`MANYOYO - AI Agent CLI Sandbox
1275
1290
  https://github.com/xcanwin/manyoyo
1276
1291
 
1277
- 不带参数直接运行,打开网页界面(首次使用进入配置向导):
1292
+ 不带参数直接运行,会启动网页服务,打开网页界面(首次使用进入配置向导):
1278
1293
  ${MANYOYO_NAME} 自动判断有无图形界面
1279
1294
  ${MANYOYO_NAME} --headless 强制按无图形界面处理(不打开浏览器,用密码登录)
1280
1295
  ${MANYOYO_NAME} --gui 强制按有图形界面处理(自动打开浏览器)`)
@@ -1429,6 +1444,7 @@ https://github.com/xcanwin/manyoyo
1429
1444
  serveCommand.option('-d, --detach', '后台启动网页服务并立即返回');
1430
1445
  serveCommand.option('--stop', '停止后台网页服务;必须显式传入 listen');
1431
1446
  serveCommand.option('--restart', '重启后台网页服务;必须显式传入 listen');
1447
+ serveCommand.option('--list', '列出正在运行的网页服务(监听地址、PID、版本、启动命令)');
1432
1448
  serveCommand.action((listen, options) => {
1433
1449
  selectAction('serve', {
1434
1450
  ...options,
@@ -1458,12 +1474,21 @@ https://github.com/xcanwin/manyoyo
1458
1474
  const pluginPlaywrightCommand = pluginCommand.command('playwright').description('管理 playwright 插件服务');
1459
1475
  registerPlaywrightAliasCommands(pluginPlaywrightCommand);
1460
1476
 
1477
+ // 安装包装完后调用的隐藏入口(不在帮助里):按有头 / 无头决定下一步
1478
+ if (process.argv[2] === '--post-install') {
1479
+ await runPostInstallCommand(process.argv.slice(3));
1480
+ process.exit(0);
1481
+ }
1482
+
1461
1483
  // No args (or only --headless / --gui): start (or reuse) the local web app; open the browser already logged in unless headless
1462
1484
  const launcherArgs = parseLauncherArgs(process.argv.slice(2));
1463
1485
  if (launcherArgs) {
1464
1486
  await runAppLauncher({ force: launcherArgs.force });
1465
1487
  }
1466
1488
 
1489
+ // 顶层 -V 与 -v 等价(commander 只允许一个短选项;子命令里的 -v 仍是 --volume,不受影响)
1490
+ if (process.argv.length === 3 && process.argv[2] === '-V') process.argv[2] = '-v';
1491
+
1467
1492
  // Pre-handle -x/--shell-full: treat all following args as a single command
1468
1493
  normalizeShellFullArgv(process.argv);
1469
1494
  normalizeWorktreeArgv(process.argv);
@@ -1489,6 +1514,10 @@ https://github.com/xcanwin/manyoyo
1489
1514
  const isServerMode = options.server !== undefined;
1490
1515
  const isServerStopMode = Boolean(selectedAction === 'serve' && options.stop);
1491
1516
  const isServerRestartMode = Boolean(selectedAction === 'serve' && options.restart);
1517
+ const isServerListMode = Boolean(selectedAction === 'serve' && options.list);
1518
+ if (isServerListMode && (isServerStopMode || isServerRestartMode || options.detach || options.server !== true)) {
1519
+ throw new Error('serve --list 不能与 listen、-d、--stop、--restart 同时使用');
1520
+ }
1492
1521
 
1493
1522
  if (isServerStopMode && isServerRestartMode) {
1494
1523
  throw new Error('serve --stop 与 --restart 不能同时使用');
@@ -1497,35 +1526,25 @@ https://github.com/xcanwin/manyoyo
1497
1526
  UPDATE_CHECK_ENABLED = config.updateCheck !== false;
1498
1527
  MIRRORS = normalizeMirrors(config.mirrors);
1499
1528
  const noDockerActions = new Set(['init', 'update', 'config-show', 'plugin', 'doctor', 'uninstall', 'podman', 'setup']);
1500
- if (isServerStopMode) {
1529
+ if (isServerStopMode || isServerListMode) {
1501
1530
  noDockerActions.add('serve');
1502
1531
  }
1503
1532
  if (!noDockerActions.has(selectedAction)) {
1504
1533
  await ensureDocker(config.containerRuntime, { deferHeal: selectedAction === 'serve' });
1505
1534
  }
1506
1535
 
1536
+ if (isServerListMode) {
1537
+ console.log(formatServeTable(listServeInstances()));
1538
+ process.exit(0);
1539
+ }
1540
+
1507
1541
  if (options.update) {
1508
1542
  await updateManyoyo(options, config);
1509
1543
  process.exit(0);
1510
1544
  }
1511
1545
 
1512
1546
  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);
1547
+ process.exit(await runSetupCommand());
1529
1548
  }
1530
1549
 
1531
1550
  if (selectedAction === 'podman') {
@@ -1578,8 +1597,10 @@ https://github.com/xcanwin/manyoyo
1578
1597
  };
1579
1598
  }
1580
1599
 
1581
- // Load run config if specified
1582
- const runConfig = options.run ? loadRunConfig(options.run, config) : {};
1600
+ // Load run config if specified;doctor 不带 -r 且 runs 里只有一个配置时,按它检查
1601
+ const runsMap = config.runs && typeof config.runs === 'object' && !Array.isArray(config.runs) ? Object.keys(config.runs) : [];
1602
+ const runNameToLoad = options.run || (selectedAction === 'doctor' && runsMap.length === 1 ? runsMap[0] : '');
1603
+ const runConfig = runNameToLoad ? loadRunConfig(runNameToLoad, config) : {};
1583
1604
  const globalFirstConfig = normalizeFirstConfig(config.first, '全局配置');
1584
1605
  const runFirstConfig = normalizeFirstConfig(runConfig.first, '运行配置');
1585
1606
 
@@ -2172,10 +2193,12 @@ async function createNewContainer(runtime) {
2172
2193
  // 使用数组参数执行命令(安全方式)
2173
2194
  try {
2174
2195
  const args = buildDockerRunArgs(runtime);
2175
- dockerExecArgs(args, { stdio: 'pipe' });
2196
+ runWithEnvFile(args, path.join(os.homedir(), '.manyoyo', 'tmp'), finalArgs => dockerExecArgs(finalArgs, { stdio: 'pipe' }));
2176
2197
  } catch (e) {
2177
2198
  showImagePullHint(e);
2178
- throw e;
2199
+ // 失败的 run 可能留下 Created 状态的容器;这个名字创建前不存在,所以只清理本次留下的这一个
2200
+ try { dockerExecArgs(['rm', '-f', runtime.containerName], { stdio: 'pipe' }); } catch (cleanupError) { /* 尽力清理 */ }
2201
+ throw explainCreateFailure(e);
2179
2202
  }
2180
2203
 
2181
2204
  // Wait for container to be ready
@@ -2220,7 +2243,7 @@ function buildDockerRunArgs(runtime) {
2220
2243
  * @returns {string} 命令字符串
2221
2244
  */
2222
2245
  function buildDockerRunCmd(runtime) {
2223
- const args = buildDockerRunArgs(runtime);
2246
+ const args = redactCommandArgs(buildDockerRunArgs(runtime));
2224
2247
  return buildContainerRunCommand(DOCKER_CMD, args);
2225
2248
  }
2226
2249
 
@@ -2349,12 +2372,66 @@ async function handlePostExit(runtime, defaultCommand) {
2349
2372
  }
2350
2373
  }
2351
2374
 
2375
+ async function runSetupCommand() {
2376
+ const interactive = Boolean(process.stdin.isTTY);
2377
+ return runSetupCli({
2378
+ isTTY: interactive,
2379
+ prompter: interactive ? createPrompter() : null,
2380
+ configPath: getManyoyoConfigPath(),
2381
+ defaultWorkpath: path.join(os.homedir(), '.manyoyo', 'work'),
2382
+ validateHostPath: value => validateHostPathOrThrow(value),
2383
+ commandName: MANYOYO_NAME,
2384
+ log: line => console.log(line),
2385
+ // 新密码要让后台服务重新读配置:先停掉旧的,再按当前环境(有头 / 无头)启动
2386
+ afterSave: async () => {
2387
+ stopBackgroundApp({ quiet: true });
2388
+ await runAppLauncher({ exit: false });
2389
+ }
2390
+ });
2391
+ }
2392
+
2393
+ async function runPostInstallCommand(args) {
2394
+ const launcher = parseLauncherArgs(args) || { force: null };
2395
+ const { headless } = detectHeadless({ force: launcher.force });
2396
+ const interactive = Boolean(process.stdin.isTTY);
2397
+ let prompter = null; // 需要提问时才创建(有图形界面、没有终端都不需要,避免多余的 readline 占着 stdin)
2398
+ try {
2399
+ await runPostInstall({
2400
+ headless,
2401
+ interactive,
2402
+ currentVersion: BIN_VERSION,
2403
+ ownRoot: path.join(os.homedir(), '.manyoyo'),
2404
+ commandName: MANYOYO_NAME,
2405
+ log: line => console.log(line),
2406
+ ask: text => {
2407
+ if (!prompter) prompter = createPrompter();
2408
+ return prompter.ask(text);
2409
+ },
2410
+ // 升级安装时先停掉旧版留下的本机服务,避免“复用”到旧代码
2411
+ startApp: () => {
2412
+ stopBackgroundApp({ quiet: true });
2413
+ return runAppLauncher({ force: launcher.force, exit: false });
2414
+ },
2415
+ // 同一个 stdin 不能同时挂两个提示器:选完之后先关掉这个,再交给 setup 自己的
2416
+ runSetup: async () => {
2417
+ if (prompter) prompter.close();
2418
+ return runSetupCommand();
2419
+ },
2420
+ listServes: () => listServeInstances()
2421
+ });
2422
+ } finally {
2423
+ if (prompter) prompter.close();
2424
+ }
2425
+ }
2426
+
2352
2427
  async function runAppLauncher({ force = null, exit = true } = {}) {
2353
2428
  try {
2354
2429
  const { headless } = detectHeadless({ force });
2430
+ const launcherConfig = readManyoyoConfig().config;
2355
2431
  await launchApp({
2356
2432
  headless,
2357
- authUser: String(readManyoyoConfig().config.serverUser || '').trim() || 'admin',
2433
+ authUser: String(launcherConfig.serverUser || '').trim() || 'admin',
2434
+ hasPassword: Boolean(String(launcherConfig.serverPass || process.env.MANYOYO_SERVER_PASS || '').trim()),
2358
2435
  statePath: getAppStatePath(),
2359
2436
  isProcessRunning,
2360
2437
  spawnServe: port => {
@@ -1,4 +1,9 @@
1
1
  {
2
+ "attribution": {
3
+ "commit": "",
4
+ "pr": "",
5
+ "sessionUrl": false
6
+ },
2
7
  "statusLine": {
3
8
  "type": "command",
4
9
  "command": "bash /root/.claude/statusline.sh"
@@ -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
  }
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
3
6
  const { buildMirrorEnvArgs } = require('./mirrors');
4
7
 
5
8
  function sanitizeDefaultCommand(defaultCommand) {
@@ -26,6 +29,38 @@ function buildContainerRunArgs(options) {
26
29
  return args;
27
30
  }
28
31
 
32
+ /**
33
+ * 把命令行里的 `--env KEY=值` 挪进 0600 的临时 env 文件,改用 `--env-file` 传入,
34
+ * 避免密钥出现在 ps / /proc 与错误信息里;exec 返回(或抛错)后立即删除文件。
35
+ * 没有 KEY=值 形式的 env 时原样执行。
36
+ */
37
+ function runWithEnvFile(args, dir, exec) {
38
+ const rest = [];
39
+ const lines = [];
40
+ let envFileIndex = -1;
41
+ for (let i = 0; i < args.length; i++) {
42
+ if (args[i] === '--env' && /^[^=]+=/.test(String(args[i + 1] ?? ''))) {
43
+ if (envFileIndex < 0) envFileIndex = rest.length;
44
+ lines.push(args[i + 1]);
45
+ i += 1;
46
+ } else {
47
+ rest.push(args[i]);
48
+ }
49
+ }
50
+ if (!lines.length) return exec(args);
51
+
52
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ fs.chmodSync(dir, 0o700);
54
+ const file = path.join(dir, `env-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
55
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
56
+ rest.splice(envFileIndex, 0, '--env-file', file);
57
+ try {
58
+ return exec(rest);
59
+ } finally {
60
+ fs.rmSync(file, { force: true });
61
+ }
62
+ }
63
+
29
64
  function quoteShellArg(value) {
30
65
  const text = String(value);
31
66
  if (text.includes(' ') || text.includes('"') || text.includes('=')) {
@@ -40,5 +75,6 @@ function buildContainerRunCommand(dockerCmd, args) {
40
75
 
41
76
  module.exports = {
42
77
  buildContainerRunArgs,
78
+ runWithEnvFile,
43
79
  buildContainerRunCommand
44
80
  };
package/lib/doctor.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { resolveContainerMode } = require('./container-modes');
4
4
  const { resolveAgentProgram } = require('./agent-resume');
5
+ const { describeError } = require('./error-hints');
6
+ const { checkRootlessNetwork } = require('./rootless-network');
5
7
 
6
8
  function createCheck(code, status, summary, action = '', detail = '') {
7
9
  return { code, status, summary, action, detail };
@@ -9,6 +11,8 @@ function createCheck(code, status, summary, action = '', detail = '') {
9
11
 
10
12
  // 卡死的 daemon(如无响应的 Docker Desktop)不能让诊断无限挂起
11
13
  const COMMAND_TIMEOUT_MS = 10000;
14
+ // 真实启动一次容器(含网络配置),比 info 慢
15
+ const START_TEST_TIMEOUT_MS = 60000;
12
16
 
13
17
  // runCommand 可以是同步或异步执行器;serve 里传异步的,避免阻塞事件循环
14
18
  async function runDoctorChecks(options = {}) {
@@ -45,13 +49,37 @@ async function runDoctorChecks(options = {}) {
45
49
  } catch (error) {
46
50
  checks.push(createCheck('DAEMON_UNAVAILABLE', 'error', `${runtimeCommand} daemon 不可用`, `启动 ${runtimeCommand} daemon 后重试。`, error.message || ''));
47
51
  }
52
+ const network = await checkRootlessNetwork(runCommand, { command: runtimeCommand, env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS, platform: options.platform });
53
+ if (network && network.missing) {
54
+ checks.push(createCheck('ROOTLESS_NETWORK_MISSING', 'error', network.reason, network.action));
55
+ } else if (network) {
56
+ checks.push(createCheck('ROOTLESS_NETWORK_OK', 'ok', `rootless 网络组件可用: ${network.component}`));
57
+ }
58
+ const image = `${options.imageName || ''}:${options.imageVersion || ''}`;
59
+ let imageAvailable = false;
48
60
  try {
49
- const image = `${options.imageName || ''}:${options.imageVersion || ''}`;
50
61
  const detail = String(await runCommand(runtimeCommand, ['image', 'inspect', image], { env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS }) || '').trim();
51
62
  checks.push(createCheck('IMAGE_AVAILABLE', 'ok', `镜像可用: ${image}`, '', detail));
63
+ imageAvailable = true;
52
64
  } catch (error) {
53
65
  checks.push(createCheck('IMAGE_MISSING', 'warning', '目标镜像尚不可用', '执行 manyoyo build,或拉取匹配镜像。', error.message || ''));
54
66
  }
67
+ // 镜像在、网络组件也没查出问题时,真实启动一次:info 正常不代表容器能起来
68
+ if (imageAvailable && !(network && network.missing)) {
69
+ try {
70
+ await runCommand(runtimeCommand, ['run', '--rm', '--entrypoint', '', image, 'true'], { env: runtimeEnv, timeout: START_TEST_TIMEOUT_MS });
71
+ checks.push(createCheck('CONTAINER_START_OK', 'ok', '容器可以正常启动'));
72
+ } catch (error) {
73
+ const raw = `${error.message || ''}\n${error.stderr || ''}`;
74
+ const info = describeError(raw, { command: runtimeCommand, imageRef: image });
75
+ checks.push(createCheck(
76
+ 'CONTAINER_START_FAILED', 'error',
77
+ info ? info.reason : '容器无法启动',
78
+ info ? info.action : `手动执行 ${runtimeCommand} run --rm ${image} true 查看原因。`,
79
+ String(error.stderr || error.message || '').trim().split('\n').slice(-5).join('\n')
80
+ ));
81
+ }
82
+ }
55
83
  }
56
84
 
57
85
  checks.push(options.configExists === true
@@ -71,9 +99,13 @@ async function runDoctorChecks(options = {}) {
71
99
  }
72
100
 
73
101
  const pluginConfig = options.pluginConfig;
74
- checks.push(pluginConfig && typeof pluginConfig === 'object' && !Array.isArray(pluginConfig)
75
- ? createCheck('PLUGIN_CONFIG_VALID', 'ok', '插件配置有效')
76
- : createCheck('PLUGIN_CONFIG_INVALID', 'warning', '插件配置不存在或格式无效', '将 plugins 配置为对象(map)。'));
102
+ if (pluginConfig === undefined || pluginConfig === null) {
103
+ checks.push(createCheck('PLUGIN_CONFIG_VALID', 'ok', '未配置插件'));
104
+ } else {
105
+ checks.push(typeof pluginConfig === 'object' && !Array.isArray(pluginConfig)
106
+ ? createCheck('PLUGIN_CONFIG_VALID', 'ok', '插件配置有效')
107
+ : createCheck('PLUGIN_CONFIG_INVALID', 'warning', '插件配置格式无效', '将 plugins 配置为对象(map)。'));
108
+ }
77
109
 
78
110
  if (options.portStatus === 'available') {
79
111
  checks.push(createCheck('PORT_AVAILABLE', 'ok', '监听端口可用'));
@@ -1,7 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const { describeMissing } = require('./rootless-network');
4
+
3
5
  // 原始运行时/系统错误 -> “原因 + 下一步”。新增映射只改这里。
4
6
  const RULES = [
7
+ {
8
+ code: 'ROOTLESS_NETWORK_MISSING',
9
+ pattern: /could not find (slirp4netns|pasta)/i,
10
+ describe: (ctx, match) => describeMissing(match[1].toLowerCase(), ctx)
11
+ },
5
12
  {
6
13
  code: 'PODMAN_MACHINE_UNAVAILABLE',
7
14
  pattern: /Cannot connect to Podman|unable to connect to Podman socket|podman machine start|podman system connection/i,
@@ -78,8 +85,9 @@ function describeError(text, context = {}) {
78
85
  const raw = String(text || '');
79
86
  const ctx = { command: 'docker', imageRef: '', port: '', ...context };
80
87
  for (const rule of RULES) {
81
- if (rule.pattern.test(raw)) {
82
- return { code: rule.code, ...rule.describe(ctx) };
88
+ const match = rule.pattern.exec(raw);
89
+ if (match) {
90
+ return { code: rule.code, ...rule.describe(ctx, match) };
83
91
  }
84
92
  }
85
93
  return null;
@@ -235,11 +235,26 @@ function formatPropertyKey(key) {
235
235
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
236
236
  }
237
237
 
238
+ // 某个位置所在行的缩进
239
+ function lineIndentAt(text, index) {
240
+ const lineStart = text.lastIndexOf('\n', index - 1) + 1;
241
+ return /^[ \t]*/.exec(text.slice(lineStart))[0];
242
+ }
243
+
244
+ // 多行值(JSON.stringify 的结果)的后续行补上插入位置的缩进,否则内层会顶格
245
+ function reindent(valueText, indent) {
246
+ return String(valueText).replace(/\n/g, `\n${indent}`);
247
+ }
248
+
238
249
  // 永远只在对象的 '{' 后面插入,不管这个对象原来是空的还是已经有属性——
239
250
  // 不用去判断"是不是最后一个属性、要不要补逗号",语法上总是安全的
240
251
  function insertObjectProperty(text, objectStartIndex, key, valueText) {
241
- const prefix = `\n ${formatPropertyKey(key)}: `;
242
- const insertion = `${prefix}${valueText},`;
252
+ const objectIndent = lineIndentAt(text, objectStartIndex);
253
+ const childIndent = `${objectIndent} `;
254
+ const prefix = `\n${childIndent}${formatPropertyKey(key)}: `;
255
+ // 空对象:闭合的 } 另起一行,不要贴在逗号后面
256
+ const closesRightAfter = text[skipTrivia(text, objectStartIndex + 1)] === '}';
257
+ const insertion = `${prefix}${reindent(valueText, childIndent)},${closesRightAfter ? `\n${objectIndent}` : ''}`;
243
258
  const insertPos = objectStartIndex + 1;
244
259
  const nextText = `${text.slice(0, insertPos)}${insertion}${text.slice(insertPos)}`;
245
260
  return { text: nextText, valueStart: insertPos + prefix.length };
@@ -269,7 +284,7 @@ function upsertValueByPath(text, pathParts, valueText) {
269
284
  if (existingRange) {
270
285
  if (isLast) {
271
286
  return applyTextReplacements(currentText, [
272
- { start: existingRange.start, end: existingRange.end, text: valueText }
287
+ { start: existingRange.start, end: existingRange.end, text: reindent(valueText, lineIndentAt(currentText, existingRange.start)) }
273
288
  ]);
274
289
  }
275
290
  const nextObjectStart = skipTrivia(currentText, existingRange.start);
@@ -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,68 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ // rootless podman 的默认网络组件:4.x 用 slirp4netns,5.x 起用 pasta
6
+ const PACKAGE_OF = { slirp4netns: 'slirp4netns', pasta: 'passt' };
7
+
8
+ function parsePodmanMajor(versionText) {
9
+ const match = /(\d+)\.\d+/.exec(String(versionText || ''));
10
+ return match ? Number(match[1]) : null;
11
+ }
12
+
13
+ function defaultNetworkComponent(major) {
14
+ return major !== null && major >= 5 ? 'pasta' : 'slirp4netns';
15
+ }
16
+
17
+ function readOsRelease() {
18
+ try {
19
+ return fs.readFileSync('/etc/os-release', 'utf8');
20
+ } catch (e) {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ function hasCommand(name) {
26
+ return String(process.env.PATH || '').split(':').some(dir => dir && fs.existsSync(`${dir}/${name}`));
27
+ }
28
+
29
+ // 与 scripts/offline/install.sh 的 Linux 分支保持同一套规则;不认识的发行版返回 null(只给说明)
30
+ function installCommand(component, { osRelease = readOsRelease(), hasDnf = hasCommand('dnf') } = {}) {
31
+ const pkg = PACKAGE_OF[component] || component;
32
+ const ids = (String(osRelease).match(/^(?:ID|ID_LIKE)=(.*)$/gm) || [])
33
+ .map(line => line.split('=')[1].replace(/["']/g, '').toLowerCase())
34
+ .join(' ')
35
+ .split(/\s+/);
36
+ if (ids.some(id => id === 'debian' || id === 'ubuntu')) return `sudo apt-get install -y ${pkg}`;
37
+ if (ids.some(id => ['fedora', 'rhel', 'centos'].includes(id))) return `sudo ${hasDnf ? 'dnf' : 'yum'} install -y ${pkg}`;
38
+ return null;
39
+ }
40
+
41
+ function describeMissing(component, options) {
42
+ const pkg = PACKAGE_OF[component] || component;
43
+ const command = installCommand(component, options);
44
+ return {
45
+ reason: `rootless Podman 缺少默认网络组件 ${component},容器无法创建网络。`,
46
+ action: command ? `请执行: ${command}` : `请用系统包管理器安装 ${pkg}(提供 ${component})。`
47
+ };
48
+ }
49
+
50
+ /**
51
+ * 检查 rootless podman 的默认网络组件是否存在(只在 Linux 的 podman 上检查)。
52
+ * @returns {Promise<null|{component: string, reason: string, action: string}>} null 表示无需处理
53
+ */
54
+ async function checkRootlessNetwork(runCommand, { command, env, timeout, platform = process.platform } = {}) {
55
+ if (platform !== 'linux' || !/(^|\/)podman$/.test(String(command || ''))) return null;
56
+ const run = async args => String(await runCommand(command, args, { env, timeout }) || '').trim();
57
+ try {
58
+ if ((await run(['info', '--format', '{{.Host.Security.Rootless}}'])) !== 'true') return null;
59
+ const component = defaultNetworkComponent(parsePodmanMajor(await run(['--version'])));
60
+ const field = component === 'pasta' ? 'Pasta' : 'Slirp4NetNS';
61
+ if (await run(['info', '--format', `{{.Host.${field}.Executable}}`])) return { component, missing: false };
62
+ return { component, missing: true, ...describeMissing(component) };
63
+ } catch (e) {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ module.exports = { parsePodmanMajor, defaultNetworkComponent, installCommand, describeMissing, checkRootlessNetwork };
@@ -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/serve-log.js CHANGED
@@ -84,6 +84,16 @@ function sanitizeSensitiveData(obj) {
84
84
  return result;
85
85
  }
86
86
 
87
+ // 命令行参数里的 KEY=值(--env / 镜像构建参数等):敏感键的值整体打码,用于错误信息与 show-command
88
+ function redactCommandArgs(args) {
89
+ const sensitive = /KEY|TOKEN|SECRET|PASSWORD|PASS|AUTH|CREDENTIAL/i;
90
+ return (Array.isArray(args) ? args : []).map(arg => {
91
+ const text = String(arg);
92
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)=/.exec(text);
93
+ return match && sensitive.test(match[1]) ? `${match[1]}=****` : text;
94
+ });
95
+ }
96
+
87
97
  function formatServeLogValue(value) {
88
98
  if (value instanceof Error) {
89
99
  return sanitizeServeLogText(value.stack || value.message || String(value));
@@ -111,6 +121,7 @@ module.exports = {
111
121
  sanitizeProcessArgv,
112
122
  sanitizeServeLogText,
113
123
  sanitizeSensitiveData,
124
+ redactCommandArgs,
114
125
  formatServeLogValue,
115
126
  getServeProcessSnapshot
116
127
  };
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
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "8.2.0",
3
+ "version": "8.3.0",
4
4
  "imageVersion": "2.1.0-common",
5
5
  "playwrightCliVersion": "0.1.21",
6
- "description": "AI Agent CLI Security Sandbox for Docker and Podman",
6
+ "description": "MANYOYO – open-source AI agent sandbox: run AI coding agents (Claude Code / Codex / Gemini) safely in Docker/Podman",
7
7
  "keywords": [
8
8
  "manyoyo",
9
9
  "ai-agent",