@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 +88 -0
- package/README.md +4 -3
- package/bin/manyoyo.js +103 -26
- package/docker/res/claude/settings.json +5 -0
- package/lib/app-launcher.js +5 -3
- package/lib/container-run.js +36 -0
- package/lib/doctor.js +36 -4
- package/lib/error-hints.js +10 -2
- package/lib/json5-text-edit.js +18 -3
- package/lib/post-install.js +143 -0
- package/lib/rootless-network.js +68 -0
- package/lib/serve-instances.js +172 -0
- package/lib/serve-log.js +11 -0
- package/lib/setup-cli.js +26 -15
- package/package.json +2 -2
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"
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2196
|
+
runWithEnvFile(args, path.join(os.homedir(), '.manyoyo', 'tmp'), finalArgs => dockerExecArgs(finalArgs, { stdio: 'pipe' }));
|
|
2176
2197
|
} catch (e) {
|
|
2177
2198
|
showImagePullHint(e);
|
|
2178
|
-
|
|
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(
|
|
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 => {
|
package/lib/app-launcher.js
CHANGED
|
@@ -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 ? '
|
|
194
|
-
log(`
|
|
195
|
-
log(`
|
|
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/container-run.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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', '监听端口可用'));
|
package/lib/error-hints.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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;
|
package/lib/json5-text-edit.js
CHANGED
|
@@ -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
|
|
242
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "8.3.0",
|
|
4
4
|
"imageVersion": "2.1.0-common",
|
|
5
5
|
"playwrightCliVersion": "0.1.21",
|
|
6
|
-
"description": "AI
|
|
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",
|