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