@xcanwin/manyoyo 7.1.6 → 8.1.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/bin/manyoyo.js CHANGED
@@ -7,15 +7,30 @@ const os = require('os');
7
7
  const crypto = require('crypto');
8
8
  const net = require('net');
9
9
  const readline = require('readline');
10
- const { Command } = require('commander');
10
+ const { Command, Help } = require('commander');
11
11
  const { startWebServer } = require('../lib/web/server');
12
12
  const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/container-run');
13
13
  const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
14
+ const { resolveUpdateImageVersion } = require('../lib/image-version-policy');
15
+ const { selectContainerRuntime, mergeRuntimeEnv } = require('../lib/container-runtime');
16
+ const { describeError } = require('../lib/error-hints');
17
+ const { ensureRuntimeReady, runCommandAsync } = require('../lib/runtime-heal');
18
+ const { ensureImagePresent, pullImageProcess } = require('../lib/image-pull');
19
+ const { readImportState, waitForImport } = require('../lib/offline-import');
20
+ const { runPodmanCommand } = require('../lib/podman-passthrough');
21
+ const { detectHeadless, parseLauncherArgs } = require('../lib/headless');
22
+ const { runSetupCli, createPrompter } = require('../lib/setup-cli');
23
+ const { pruneDanglingImages: pruneDanglingImagesSafely } = require('../lib/image-prune');
24
+ const { runUninstall, readPid, defaultIsManyoyoServe, defaultKill } = require('../lib/uninstall');
25
+ const appUpdate = require('../lib/app-update');
26
+ const { createUpdateChecker } = require('../lib/update-check');
27
+ const { getLoginTokenDir, issueLoginToken } = require('../lib/login-token');
28
+ const { launchApp, getAppStatePath, openBrowser } = require('../lib/app-launcher');
14
29
  const { initAgentConfigs } = require('../lib/init-config');
15
30
  const { buildImage } = require('../lib/image-build');
16
31
  const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
17
32
  const { resolveYoloCommand } = require('../lib/agent-adapters');
18
- const { runDoctorChecks } = require('../lib/doctor');
33
+ const { runDoctorChecks, applyDoctorFixes } = require('../lib/doctor');
19
34
  const { resolveContainerMode } = require('../lib/container-modes');
20
35
  const { runPluginCommand, createPlugin } = require('../lib/plugin');
21
36
  const { buildManyoyoLogPath } = require('../lib/log-path');
@@ -23,8 +38,10 @@ const { resolveRuntimeConfig } = require('../lib/runtime-resolver');
23
38
  const { resolveWorktreeSupport } = require('../lib/worktrees');
24
39
  const {
25
40
  parseEnvEntry: parseEnvEntryOrThrow,
26
- normalizeVolume
41
+ normalizeVolume,
42
+ normalizeMirrors
27
43
  } = require('../lib/runtime-normalizers');
44
+ const { applyAptMirror } = require('../lib/mirrors');
28
45
  const {
29
46
  sanitizeSensitiveData,
30
47
  sanitizeServeLogText,
@@ -33,8 +50,7 @@ const {
33
50
  } = require('../lib/serve-log');
34
51
  const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
35
52
  const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
36
- const GLOBAL_CONFIG_IMAGE_VERSION = String(readManyoyoConfig().config.imageVersion || '').trim();
37
- const IMAGE_VERSION_HELP_EXAMPLE = GLOBAL_CONFIG_IMAGE_VERSION || IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
53
+ const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
38
54
 
39
55
  // Helper function to format date like bash $(date +%m%d-%H%M)
40
56
  function formatDate() {
@@ -47,14 +63,13 @@ function formatDate() {
47
63
  }
48
64
 
49
65
  function detectCommandName() {
66
+ // 离线安装器生成的 manyoyo / my 包装脚本都指向同一个 manyoyo.js,用环境变量区分显示的命令名
67
+ const fromEnv = String(process.env.MANYOYO_COMMAND_NAME || '').trim();
68
+ if (/^[A-Za-z][A-Za-z0-9_-]*$/.test(fromEnv)) return fromEnv;
69
+
50
70
  const rawArgv1 = process.argv[1] || '';
51
71
  const baseName = path.basename(rawArgv1).replace(/\.(cjs|mjs|js)$/i, '');
52
72
 
53
- if (baseName === 'docker-manyoyo') {
54
- const pluginCommand = String(process.argv[2] || '').trim();
55
- return pluginCommand || 'manyoyo';
56
- }
57
-
58
73
  return baseName || 'manyoyo';
59
74
  }
60
75
 
@@ -68,7 +83,7 @@ const CONFIG = {
68
83
  let CONTAINER_NAME = `my-${formatDate()}`;
69
84
  let HOST_PATH = process.cwd();
70
85
  let CONTAINER_PATH = HOST_PATH;
71
- let IMAGE_NAME = "localhost/xcanwin/manyoyo";
86
+ let IMAGE_NAME = "ghcr.io/xcanwin/manyoyo";
72
87
  let IMAGE_VERSION = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
73
88
  let EXEC_COMMAND = "";
74
89
  let EXEC_COMMAND_PREFIX = "";
@@ -105,6 +120,16 @@ const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)
105
120
 
106
121
  // Docker command (will be set by ensure_docker)
107
122
  let DOCKER_CMD = 'docker';
123
+ // 仅运行时子进程使用的完整 env(私有 Podman 才有值),不要写回 process.env
124
+ let DOCKER_ENV;
125
+ let CONTAINER_RUNTIME = null;
126
+ // 全局配置 updateCheck(默认 true):serve 是否每天检查一次新版本
127
+ let UPDATE_CHECK_ENABLED = true;
128
+ // 全局配置 mirrors(apt/npm/pip 软件源,空 = 官方默认):容器创建时在容器层生效,镜像不变
129
+ let MIRRORS = { apt: '', npm: '', pip: '' };
130
+ // serve 的容器环境状态,供 GET /api/system/runtime 读取
131
+ const RUNTIME_STATE = { status: 'ready', message: '' };
132
+ const DOCKER_DAEMON_ERROR_CODES = new Set(['PODMAN_MACHINE_UNAVAILABLE', 'DOCKER_DAEMON_UNAVAILABLE', 'PORT_IN_USE']);
108
133
  const SUPPORTED_INIT_AGENTS = ['claude', 'codex', 'gemini', 'opencode'];
109
134
 
110
135
  function sleep(ms) {
@@ -312,6 +337,9 @@ function installServeProcessDiagnostics(logger) {
312
337
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
313
338
  * @property {string} [yolo] - YOLO 模式
314
339
  * @property {string} [containerMode] - 容器模式
340
+ * @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
341
+ * @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
342
+ * @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
315
343
  * @property {number} [cacheTTL] - 缓存过期天数
316
344
  * @property {string} [nodeMirror] - Node.js 镜像源
317
345
  */
@@ -438,7 +466,7 @@ function parseImageVersionTag(version) {
438
466
  function validateImageVersion(value) {
439
467
  validateName('imageVersion', value, /^[A-Za-z0-9][A-Za-z0-9_.-]*$/);
440
468
  if (!parseImageVersionTag(value)) {
441
- console.error(`${RED}⚠️ 错误: imageVersion 格式必须为 <x.y.z-后缀>,例如 1.7.4-common。当前值: ${value}${NC}`);
469
+ console.error(`${RED}⚠️ 错误: imageVersion 格式必须为 <x.y.z-后缀>,例如 ${IMAGE_VERSION_HELP_EXAMPLE}。当前值: ${value}${NC}`);
442
470
  process.exit(1);
443
471
  }
444
472
  }
@@ -672,17 +700,39 @@ function setContMode(mode) {
672
700
  }
673
701
  }
674
702
 
703
+ // 本地没有镜像时自动拉取(终端显示拉取进度),失败给出原因与下一步
704
+ async function ensureRunImage(runtime) {
705
+ const imageRef = `${runtime.imageName}:${runtime.imageVersion}`;
706
+ // 离线安装器还在后台导入镜像时先等它,避免重复去仓库拉
707
+ await waitForImport({ onWait: state => console.log(`${YELLOW}⏳ ${state.message},等待完成...${NC}`) });
708
+ await ensureImagePresent({
709
+ imageRef,
710
+ command: DOCKER_CMD,
711
+ isPresent: () => {
712
+ try {
713
+ dockerExecArgs(['image', 'inspect', imageRef], { stdio: 'pipe' });
714
+ return true;
715
+ } catch (e) {
716
+ return false;
717
+ }
718
+ },
719
+ pull: () => pullImageProcess({
720
+ command: DOCKER_CMD,
721
+ env: DOCKER_ENV,
722
+ imageRef,
723
+ onOutput: (text, stream) => (stream === 'stderr' ? process.stderr : process.stdout).write(text)
724
+ }),
725
+ onStart: () => console.log(`${YELLOW}⏬ 本地没有镜像 ${imageRef},正在拉取...${NC}`)
726
+ });
727
+ }
728
+
675
729
  function showImagePullHint(err) {
676
- const stderr = err && err.stderr ? err.stderr.toString() : '';
677
- const stdout = err && err.stdout ? err.stdout.toString() : '';
678
- const message = err && err.message ? err.message : '';
679
- const combined = `${message}\n${stderr}\n${stdout}`;
680
- if (!/localhost\/v2|pinging container registry localhost|connection refused|dial tcp .*:443/i.test(combined)) {
730
+ const info = describeError(getCommandFailureText(err), { imageRef: `${IMAGE_NAME}:${IMAGE_VERSION}` });
731
+ if (!info || !['IMAGE_PULL_FAILED', 'IMAGE_NOT_FOUND'].includes(info.code)) {
681
732
  return;
682
733
  }
683
- const image = `${IMAGE_NAME}:${IMAGE_VERSION}`;
684
- console.log(`${YELLOW}💡 提示: 本地未找到镜像 ${image},并且从 localhost 注册表拉取失败。${NC}`);
685
- console.log(`${YELLOW} 你可以: (1) 更新 ~/.manyoyo/manyoyo.json 的 imageVersion。 (2) 或先执行 ${MANYOYO_NAME} build --iv <x.y.z-后缀> 构建镜像。${NC}`);
734
+ console.log(`${YELLOW}💡 提示: ${info.reason}${NC}`);
735
+ console.log(`${YELLOW} ${info.action}${NC}`);
686
736
  }
687
737
 
688
738
  function getCommandFailureText(err) {
@@ -692,25 +742,6 @@ function getCommandFailureText(err) {
692
742
  return `${message}\n${stderr}\n${stdout}`;
693
743
  }
694
744
 
695
- function getContainerRuntimeUnavailableHint(command, err) {
696
- const text = getCommandFailureText(err);
697
- if (/Cannot connect to Podman|unable to connect to Podman socket|podman machine start|podman system connection/i.test(text)) {
698
- return [
699
- '',
700
- `提示: 当前 ${command} 命令正在连接 Podman machine,但连接不可用。`,
701
- '请先在宿主机执行: podman machine start'
702
- ].join('\n');
703
- }
704
- if (/Cannot connect to the Docker daemon|docker daemon is not running|Is the docker daemon running/i.test(text)) {
705
- return [
706
- '',
707
- `提示: 当前 ${command} 命令无法连接容器运行时。`,
708
- '请先启动 Docker Desktop / Docker daemon,或确认 Podman machine 已启动。'
709
- ].join('\n');
710
- }
711
- return '';
712
- }
713
-
714
745
  function runCmd(cmd, args, options = {}) {
715
746
  const result = spawnSync(cmd, args, { encoding: 'utf-8', ...options });
716
747
  if (result.error) {
@@ -739,11 +770,12 @@ function checkPortAvailability(port) {
739
770
 
740
771
  function dockerExecArgs(args, options = {}) {
741
772
  try {
742
- return runCmd(DOCKER_CMD, args, options);
773
+ return runCmd(DOCKER_CMD, args, { env: DOCKER_ENV, ...options });
743
774
  } catch (e) {
744
- const hint = getContainerRuntimeUnavailableHint(DOCKER_CMD, e);
745
- if (hint && e && e.message && !e.message.includes(hint)) {
746
- e.message = `${e.message}${hint}`;
775
+ const info = describeError(getCommandFailureText(e), { command: DOCKER_CMD });
776
+ if (info && DOCKER_DAEMON_ERROR_CODES.has(info.code) && e && e.message) {
777
+ const hint = `\n提示: ${info.reason}\n${info.action}`;
778
+ if (!e.message.includes(hint)) e.message = `${e.message}${hint}`;
747
779
  }
748
780
  throw e;
749
781
  }
@@ -764,40 +796,169 @@ function removeContainer(name) {
764
796
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${GREEN}✅ 已彻底删除。${NC}`);
765
797
  }
766
798
 
767
- function ensureDocker() {
768
- const commands = ['docker', 'podman'];
769
- for (const cmd of commands) {
799
+ // 运行时子进程执行器:env 只是增量,合并后仅传给这个子进程
800
+ function runRuntimeCommand(command, args, options = {}) {
801
+ return runCmd(command, args, {
802
+ stdio: 'pipe',
803
+ timeout: options.timeout,
804
+ env: mergeRuntimeEnv(options.env)
805
+ });
806
+ }
807
+
808
+ // serve 里的自愈用异步执行器:machine start 最长 120s,同步执行会冻住整个事件循环(向导进度、登录页都没响应)
809
+ function runRuntimeCommandAsync(command, args, options = {}) {
810
+ return runCommandAsync(command, args, { timeout: options.timeout, env: mergeRuntimeEnv(options.env) });
811
+ }
812
+
813
+ function healContainerRuntime(onStatus, options = {}) {
814
+ return ensureRuntimeReady({ runtime: CONTAINER_RUNTIME, run: options.async ? runRuntimeCommandAsync : runRuntimeCommand, onStatus });
815
+ }
816
+
817
+ // 选择阶段已确认 daemon 可用(info 通过)时无需再自愈
818
+ function isRuntimeProven() {
819
+ return /-daemon$/.test(CONTAINER_RUNTIME.source);
820
+ }
821
+
822
+ async function ensureDocker(configuredRuntime, options = {}) {
823
+ try {
824
+ CONTAINER_RUNTIME = selectContainerRuntime({ configured: configuredRuntime });
825
+ } catch (e) {
826
+ console.error(e.message);
827
+ process.exit(1);
828
+ }
829
+ DOCKER_CMD = CONTAINER_RUNTIME.command;
830
+ DOCKER_ENV = mergeRuntimeEnv(CONTAINER_RUNTIME.env);
831
+ if (options.deferHeal || isRuntimeProven()) {
832
+ return true;
833
+ }
834
+
835
+ const result = await healContainerRuntime(state => {
836
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
837
+ });
838
+ if (result.status === 'started') {
839
+ console.log(`${GREEN}✅ 容器环境已就绪${NC}`);
840
+ } else if (result.status !== 'ready') {
841
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
842
+ }
843
+ return true;
844
+ }
845
+
846
+ // 升级后让旧版本的后台服务退出(pid 必须确实是 manyoyo serve),下次执行 manyoyo 就会启动新版本
847
+ function stopBackgroundApp({ quiet = false } = {}) {
848
+ const pid = readPid(path.join(os.homedir(), '.manyoyo', 'serve', 'app.json'));
849
+ if (pid && pid !== process.pid && defaultIsManyoyoServe(pid) && defaultKill(pid)) {
850
+ if (!quiet) console.log(`${GREEN}✅ 已停止旧版本的后台服务 (pid ${pid}),下次执行 ${MANYOYO_NAME} 会启动新版本。${NC}`);
851
+ }
852
+ }
853
+
854
+ // 升级流程里准备容器运行时:和 ensureDocker 一样选择并(必要时)自愈,但没有运行时也不退出进程
855
+ async function prepareRuntimeForUpdate(configuredRuntime) {
856
+ try {
857
+ CONTAINER_RUNTIME = selectContainerRuntime({ configured: configuredRuntime });
858
+ } catch (error) {
859
+ return false;
860
+ }
861
+ DOCKER_CMD = CONTAINER_RUNTIME.command;
862
+ DOCKER_ENV = mergeRuntimeEnv(CONTAINER_RUNTIME.env);
863
+ if (!isRuntimeProven()) {
864
+ const result = await healContainerRuntime(state => {
865
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
866
+ });
867
+ if (result.status !== 'ready' && result.status !== 'started') {
868
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
869
+ return false;
870
+ }
871
+ }
872
+ return true;
873
+ }
874
+
875
+ async function updateOfflineInstall(mode, options, globalConfig) {
876
+ const { appRoot } = mode;
877
+ if (options.rollback) {
878
+ const result = appUpdate.rollbackApp({ appRoot });
879
+ console.log(`${GREEN}✅ 已回滚: ${result.from || '未知'} → ${result.to}${NC}`);
880
+ stopBackgroundApp();
881
+ return;
882
+ }
883
+
884
+ const installed = appUpdate.currentVersion(appRoot) || mode.version;
885
+ console.log(`${CYAN}🔄 当前版本: ${installed}${NC}`);
886
+ console.log(`${CYAN}🔄 正在查询最新版本...${NC}`);
887
+ const release = await appUpdate.fetchLatestRelease();
888
+ if (appUpdate.compareVersions(release.version, installed) <= 0) {
889
+ console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
890
+ return;
891
+ }
892
+ console.log(`${CYAN}⬇️ 发现新版本 ${release.version},只下载 manyoyo 本体(数十 MB)${NC}`);
893
+ const result = await appUpdate.installAppUpdate({ appRoot, release, targetOs: appUpdate.platformOs(), log: line => console.log(` ${line}`) });
894
+ console.log(`${GREEN}✅ 更新完成: ${installed} → ${result.version}(上一版本 ${result.previous || '无'} 已保留,可用 ${MANYOYO_NAME} update --rollback 回滚)${NC}`);
895
+
896
+ // Podman / VM 磁盘有变化只提示(需要新的完整包)
897
+ const runtimeHint = appUpdate.describeRuntimeChange(appUpdate.readInstalledRecord(os.homedir()), result.manifest.runtime);
898
+ if (runtimeHint) console.log(`${YELLOW}ℹ️ ${runtimeHint}${NC}`);
899
+
900
+ // 新版本要求的镜像不存在就拉取;仍用旧镜像的容器只列出来,不动
901
+ const imageName = String(globalConfig.imageName || 'ghcr.io/xcanwin/manyoyo');
902
+ const versionPolicy = resolveUpdateImageVersion({
903
+ configured: globalConfig.imageVersion,
904
+ previousDefault: require('../package.json').imageVersion,
905
+ nextDefault: result.manifest.imageVersion
906
+ });
907
+ const imageVersion = versionPolicy.imageVersion;
908
+ if (versionPolicy.advance) {
909
+ const synced = syncGlobalImageVersion(imageVersion);
910
+ console.log(`${CYAN}ℹ️ 配置里的 imageVersion(${globalConfig.imageVersion})是旧版本默认值,已随新版本更新为 ${imageVersion}${synced.updated ? '' : '(写入配置失败,请手动修改)'}${NC}`);
911
+ } else if (versionPolicy.pinned) {
912
+ console.log(`${YELLOW}ℹ️ 你在配置里固定了 imageVersion=${imageVersion},新版本默认是 ${result.manifest.imageVersion};如需使用新镜像请修改 ~/.manyoyo/manyoyo.json${NC}`);
913
+ }
914
+ if (imageVersion && await prepareRuntimeForUpdate(globalConfig.containerRuntime)) {
915
+ const imageRef = `${imageName}:${imageVersion}`;
770
916
  try {
771
- runCmd(cmd, ['--version'], { stdio: 'pipe' });
772
- DOCKER_CMD = cmd;
773
- return true;
774
- } catch (e) {
775
- // Try next command
917
+ await ensureImagePresent({
918
+ imageRef,
919
+ command: DOCKER_CMD,
920
+ isPresent: () => {
921
+ try {
922
+ dockerExecArgs(['image', 'inspect', imageRef], { stdio: 'pipe' });
923
+ return true;
924
+ } catch (e) {
925
+ return false;
926
+ }
927
+ },
928
+ pull: () => pullImageProcess({ command: DOCKER_CMD, env: DOCKER_ENV, imageRef, onOutput: (text, stream) => (stream === 'stderr' ? process.stderr : process.stdout).write(text) }),
929
+ onStart: () => console.log(`${YELLOW}⏬ 新版本需要镜像 ${imageRef},正在拉取...${NC}`)
930
+ });
931
+ } catch (error) {
932
+ console.log(`${YELLOW}⚠️ 镜像还没准备好:${error.message}${NC}`);
933
+ }
934
+ const outdated = appUpdate.findOutdatedContainers({ run: runCmd, runtime: { command: DOCKER_CMD, env: DOCKER_ENV }, imageRef });
935
+ if (outdated.length > 0) {
936
+ console.log(`${YELLOW}ℹ️ 有 ${outdated.length} 个容器还在用旧镜像:${outdated.map(item => `${item.name}(${item.image})`).join('、')}${NC}`);
937
+ console.log(`${YELLOW} 它们可以继续使用;想换成新镜像,请新建容器(会话历史与凭据都在宿主机上,不会丢)。${NC}`);
776
938
  }
777
939
  }
778
- console.error("docker/podman not found");
779
- process.exit(1);
940
+ stopBackgroundApp();
780
941
  }
781
942
 
782
- function installManyoyo(name) {
783
- const MANYOYO_FILE = fs.realpathSync(__filename);
784
- switch (name) {
785
- case 'docker-cli-plugin':
786
- const pluginDir = path.join(process.env.HOME, '.docker/cli-plugins');
787
- fs.mkdirSync(pluginDir, { recursive: true });
788
- const targetPath = path.join(pluginDir, 'docker-manyoyo');
789
- if (fs.existsSync(targetPath)) {
790
- fs.unlinkSync(targetPath);
943
+ async function updateManyoyo(options = {}, globalConfig = {}) {
944
+ const mode = appUpdate.detectInstallMode({ scriptPath: __filename });
945
+ if (mode.mode === 'offline') {
946
+ try {
947
+ await updateOfflineInstall(mode, options, globalConfig);
948
+ } catch (error) {
949
+ if (error instanceof appUpdate.UpdateError) {
950
+ console.error(`${RED}❌ ${error.message}${NC}`);
951
+ process.exit(1);
791
952
  }
792
- fs.symlinkSync(MANYOYO_FILE, targetPath);
793
- break;
794
- default:
795
- console.log("");
953
+ throw error;
954
+ }
955
+ return;
956
+ }
957
+ if (options.rollback) {
958
+ console.error(`${RED}❌ --rollback 只适用于离线包安装;npm 安装的版本请用 npm install -g @xcanwin/manyoyo@<版本> 回退。${NC}`);
959
+ process.exit(1);
796
960
  }
797
- process.exit(0);
798
- }
799
961
 
800
- function updateManyoyo() {
801
962
  let isLocalFileInstall = false;
802
963
  let currentVersion = 'unknown';
803
964
 
@@ -902,47 +1063,11 @@ function getImageList() {
902
1063
  }
903
1064
 
904
1065
  function pruneDanglingImages() {
905
- console.log(`\n${YELLOW}清理悬空镜像...${NC}`);
906
- dockerExecArgs(['image', 'prune', '-f'], { stdio: 'inherit' });
907
-
908
- // Remove remaining <none> images
909
- try {
910
- const imagesOutput = dockerExecArgs(['images', '-a', '--format', '{{.ID}} {{.Repository}}']);
911
- const noneImages = imagesOutput
912
- .split('\n')
913
- .filter(line => line.includes('<none>'))
914
- .map(line => line.split(' ')[0])
915
- .filter(id => id);
916
-
917
- if (noneImages.length > 0) {
918
- console.log(`${YELLOW}清理剩余的 <none> 镜像 (${noneImages.length} 个)...${NC}`);
919
- dockerExecArgs(['rmi', '-f', ...noneImages], { stdio: 'inherit' });
920
- }
921
- } catch (e) {
922
- // Ignore errors if no <none> images found
923
- }
924
-
925
- console.log(`${GREEN}✅ 清理完成${NC}`);
926
- }
927
-
928
- function maybeHandleDockerPluginMetadata(argv) {
929
- if (argv[2] !== 'docker-cli-plugin-metadata') {
930
- return false;
931
- }
932
- console.log(JSON.stringify({
933
- "SchemaVersion": "0.1.0",
934
- "Vendor": "xcanwin",
935
- "Version": "v1.0.0",
936
- "Description": "AI Agent CLI Sandbox"
937
- }, null, 4));
938
- return true;
939
- }
940
-
941
- function normalizeDockerPluginArgv(argv) {
942
- const dockerPluginPath = path.join(process.env.HOME || '', '.docker/cli-plugins/docker-manyoyo');
943
- if (argv[1] === dockerPluginPath && argv[2] === 'manyoyo') {
944
- argv.splice(2, 1);
945
- }
1066
+ pruneDanglingImagesSafely({
1067
+ dockerExecArgs,
1068
+ log: line => console.log(line),
1069
+ colors: { YELLOW, GREEN, NC }
1070
+ });
946
1071
  }
947
1072
 
948
1073
  function normalizeShellFullArgv(argv) {
@@ -1006,7 +1131,7 @@ function applyRunStyleOptions(command, options = {}) {
1006
1131
  .option('--cp, --cont-path <path>', '设置容器工作目录')
1007
1132
  .option('-m, --cont-mode <mode>', '设置容器嵌套模式 (common, dind, sock; 注意: sock 模式可访问宿主机 Docker socket,风险较高)')
1008
1133
  .option('--in, --image-name <name>', '指定镜像名称')
1009
- .option('--iv, --image-ver <version>', '指定镜像版本 (格式: x.y.z-后缀,如 1.7.4-common)');
1134
+ .option('--iv, --image-ver <version>', `指定镜像版本 (格式: x.y.z-后缀,如 ${IMAGE_VERSION_HELP_EXAMPLE})`);
1010
1135
 
1011
1136
  appendArrayOption(command, '-e, --env <env>', '设置环境变量 XXX=YYY (可多次使用)');
1012
1137
  appendArrayOption(command, '--ef, --env-file <file>', '从环境文件加载变量 (仅支持绝对路径,如 /abs/path.env; 相对路径会报错)');
@@ -1023,7 +1148,7 @@ function applyRunStyleOptions(command, options = {}) {
1023
1148
  .option('--first-shell <command>', '首次预执行命令 (仅新建容器生效; 容器已存在时忽略)')
1024
1149
  .option('--first-shell-suffix <command>', '首次预执行命令后缀 (仅新建容器生效; 容器已存在时忽略)')
1025
1150
  .option('-x, --shell-full <command...>', '完整命令 (与 --sp/-s/--ss/-- 互斥)')
1026
- .option('-y, --yolo <cli>', '使 AGENT 无需确认 (claude(c), gemini(gm), codex(cx), opencode(oc))');
1151
+ .option('-y, --yolo <cli>', '以免确认模式启动 Agent: c=Claude, cx=Codex, gm=Gemini, oc=OpenCode');
1027
1152
  appendArrayOption(command, '--first-env <env>', '首次预执行环境变量 XXX=YYY (可多次使用)');
1028
1153
  appendArrayOption(command, '--first-env-file <file>', '首次预执行环境变量文件 (仅支持绝对路径,如 /abs/path.env)');
1029
1154
 
@@ -1031,7 +1156,7 @@ function applyRunStyleOptions(command, options = {}) {
1031
1156
  command.option('--rm-on-exit', '退出后自动删除容器 (一次性模式)');
1032
1157
  }
1033
1158
 
1034
- appendArrayOption(command, '-q, --quiet <item>', '静默输出 (可多次使用: cnew, crm, tip, cmd, full)');
1159
+ appendArrayOption(command, '-q, --quiet <item>', '隐藏部分输出 (可多次使用,可选项见下方“-q 可选项”)');
1035
1160
 
1036
1161
  if (includeServePreview) {
1037
1162
  command
@@ -1136,46 +1261,82 @@ async function setupCommander() {
1136
1261
 
1137
1262
  program
1138
1263
  .name(MANYOYO_NAME)
1264
+ .optionsGroup('选项:')
1265
+ .commandsGroup('其他:')
1139
1266
  .version(BIN_VERSION, '-v, --version', '显示版本')
1140
- .description('MANYOYO - AI Agent CLI Sandbox\nhttps://github.com/xcanwin/manyoyo')
1267
+ .helpOption('-h, --help', '显示帮助')
1268
+ .helpCommand('help [command]', '显示指定命令的帮助')
1269
+ .configureHelp({
1270
+ optionDescription(option) {
1271
+ return Help.prototype.optionDescription.call(this, option).replace(/\s*\(default: \[\]\)/, '');
1272
+ }
1273
+ })
1274
+ .description(`MANYOYO - AI Agent CLI Sandbox
1275
+ https://github.com/xcanwin/manyoyo
1276
+
1277
+ 不带参数直接运行,打开网页界面(首次使用进入配置向导):
1278
+ ${MANYOYO_NAME} 自动判断有无图形界面
1279
+ ${MANYOYO_NAME} --headless 强制按无图形界面处理(不打开浏览器,用密码登录)
1280
+ ${MANYOYO_NAME} --gui 强制按有图形界面处理(自动打开浏览器)`)
1141
1281
  .addHelpText('after', `
1142
- 配置文件:
1143
- ~/.manyoyo/manyoyo.json 全局配置文件 (JSON5格式,支持注释)
1144
- ~/.manyoyo/run/c.json 运行配置示例
1145
-
1146
- 路径规则:
1147
- run -r name → ~/.manyoyo/manyoyo.json 的 runs.name
1148
- run --ef /abs/path.env → 绝对路径环境文件
1149
- run --ss "<args>" → 显式设置命令后缀
1150
- run -- <args...> → 直接透传命令后缀(优先级最高)
1151
-
1152
1282
  示例:
1153
- ${MANYOYO_NAME} update 更新 MANYOYO 到最新版本
1154
- ${MANYOYO_NAME} build --iv ${IMAGE_VERSION_HELP_EXAMPLE} --yes 构建镜像
1155
- ${MANYOYO_NAME} build --update-agents --yes 仅更新已有镜像内已存在的 Agent CLI
1156
- ${MANYOYO_NAME} init all 从本机 Agent 配置初始化 ~/.manyoyo
1157
- ${MANYOYO_NAME} run -r claude 使用 manyoyo.json 的 runs.claude 快速启动
1158
- ${MANYOYO_NAME} run -r codex --ss "resume --last" 使用命令后缀
1159
- ${MANYOYO_NAME} run -n test --ef /path/ab.env -y c 使用绝对路径环境变量文件
1160
- ${MANYOYO_NAME} run -n test -- -c 恢复之前会话
1161
- ${MANYOYO_NAME} run -x "echo 123" 使用完整命令
1162
- ${MANYOYO_NAME} serve 127.0.0.1:3000 启动本机网页服务
1163
- ${MANYOYO_NAME} serve 127.0.0.1:3000 -d 后台启动;未设密码时会打印本次随机密码
1164
- ${MANYOYO_NAME} serve 0.0.0.0:3000 -U admin -P 123 -d 后台启动并监听全部网卡
1165
- ${MANYOYO_NAME} serve 0.0.0.0:3000 -U admin -P 123 -d --restart 重启指定后台网页服务
1166
- ${MANYOYO_NAME} playwright up mcp-host-headless 启动 playwright MCP 宿主场景(默认/推荐)
1167
- ${MANYOYO_NAME} playwright up cli-host-headless 启动 playwright CLI 宿主场景(供容器内 playwright-cli 附着)
1168
- ${MANYOYO_NAME} run -n test -q tip -q cmd 多次使用静默选项
1283
+ ${MANYOYO_NAME} 打开网页界面
1284
+ ${MANYOYO_NAME} init all 导入本机已有的 Agent 配置
1285
+ ${MANYOYO_NAME} run -y c 在沙箱里以免确认模式启动 Claude Code
1286
+ ${MANYOYO_NAME} run -r claude 使用 manyoyo.json 的 runs.claude 启动
1287
+ ${MANYOYO_NAME} update 升级到最新版本
1288
+
1289
+ 配置文件: ~/.manyoyo/manyoyo.json (JSON5 格式,支持注释)
1290
+ 各命令的更多用法: ${MANYOYO_NAME} <命令> --help
1169
1291
  `);
1170
1292
 
1171
- const runCommand = program.command('run').description('启动(容器不存在时)或连接(容器已存在时)容器并执行命令');
1293
+ // 日常
1294
+ program.command('update')
1295
+ .helpGroup('日常:')
1296
+ .description('升级到最新版本')
1297
+ .option('--rollback', '回到上一版本')
1298
+ .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback) }));
1299
+
1300
+ program.command('uninstall')
1301
+ .helpGroup('日常:')
1302
+ .description('卸载 MANYOYO(配置和数据默认保留)')
1303
+ .option('--yes', '确认卸载程序本身,不再询问(不会删除配置、历史、日志、工作目录和外部运行时里的容器镜像)')
1304
+ .action(options => selectAction('uninstall', options));
1305
+
1306
+ program.command('setup')
1307
+ .helpGroup('日常:')
1308
+ .description('命令行配置向导(无图形界面时使用)')
1309
+ .action(() => selectAction('setup', {}));
1310
+
1311
+ program.command('doctor')
1312
+ .helpGroup('日常:')
1313
+ .description('诊断容器运行时、镜像、配置和端口')
1314
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1315
+ .option('--port <port>', '检查指定监听端口')
1316
+ .option('--json', '以 JSON 输出稳定诊断结果')
1317
+ .option('--fix', '自动修复可修复项(启动容器环境、拉取镜像、生成默认配置,端口占用时给出建议端口)')
1318
+ .action(options => selectAction('doctor', { ...options, doctor: true }));
1319
+
1320
+ // 命令行运行
1321
+ const runCommand = program.command('run').helpGroup('命令行运行:').description('启动容器并运行命令(容器已存在则连接)');
1172
1322
  runCommand.addHelpText('after', `
1173
- Examples:
1323
+ 示例:
1174
1324
  ${MANYOYO_NAME} run -r codex
1325
+ ${MANYOYO_NAME} run -n test --ef /path/ab.env -y c
1326
+ ${MANYOYO_NAME} run -n test -- -c
1327
+ ${MANYOYO_NAME} run -r codex --ss "resume --last"
1175
1328
  ${MANYOYO_NAME} run --rm-on-exit -x /bin/bash -lc "node -v"
1176
1329
  ${MANYOYO_NAME} run -n demo --first-shell "npm ci" -s "npm test"
1177
1330
 
1178
- Notes:
1331
+ -q 可选项(可多次使用):
1332
+ cnew 隐藏“创建/连接容器”提示
1333
+ crm 隐藏“删除容器”提示
1334
+ tip 隐藏首次命令与恢复会话提示
1335
+ cmd 隐藏将执行的命令
1336
+ askkeep 简化“是否保留容器”的提问
1337
+ full 隐藏以上全部
1338
+
1339
+ 说明:
1179
1340
  参数优先级与合并规则(标量覆盖、数组追加、env 按 key 合并)请用 ${MANYOYO_NAME} config show --help 或查看文档。
1180
1341
  `);
1181
1342
  applyRunStyleOptions(runCommand);
@@ -1185,30 +1346,85 @@ Notes:
1185
1346
  selectAction('run', options);
1186
1347
  });
1187
1348
 
1188
- const buildCommand = program.command('build').description('构建 manyoyo 沙箱镜像');
1349
+ const initCommand = program.command('init [agents]').helpGroup('命令行运行:').description('导入本机已有的 Agent 配置到 ~/.manyoyo');
1350
+ initCommand
1351
+ .option('--yes', '所有提示自动确认 (用于CI/脚本)')
1352
+ .action((agents, options) => selectAction('init', { ...options, initConfig: agents === undefined ? 'all' : agents }));
1353
+
1354
+ const configCommand = program.command('config').helpGroup('命令行运行:').description('查看生效的配置或将执行的命令');
1355
+ const configShowCommand = configCommand.command('show').description('显示最终生效的配置');
1356
+ applyRunStyleOptions(configShowCommand, { includeRmOnExit: false, includeServePreview: true });
1357
+ enableShellSuffixPassThrough(configShowCommand);
1358
+ configShowCommand.action((options, command) => {
1359
+ validateShellSuffixPassThroughArgs(command);
1360
+ const finalOptions = {
1361
+ ...options,
1362
+ showConfig: true
1363
+ };
1364
+ if (options.serve !== undefined) {
1365
+ finalOptions.server = options.serve;
1366
+ finalOptions.serverUser = options.user;
1367
+ finalOptions.serverPass = options.pass;
1368
+ }
1369
+ selectAction('config-show', finalOptions);
1370
+ });
1371
+
1372
+ const configRunCommand = configCommand.command('command').description('显示将执行的容器命令');
1373
+ applyRunStyleOptions(configRunCommand, { includeRmOnExit: false });
1374
+ enableShellSuffixPassThrough(configRunCommand);
1375
+ configRunCommand.action((options, command) => {
1376
+ validateShellSuffixPassThroughArgs(command);
1377
+ selectAction('config-command', options);
1378
+ });
1379
+
1380
+ // 容器与镜像
1381
+ program.command('ps')
1382
+ .helpGroup('容器与镜像:')
1383
+ .description('列出容器')
1384
+ .action(() => selectAction('ps', { contList: true }));
1385
+
1386
+ program.command('images')
1387
+ .helpGroup('容器与镜像:')
1388
+ .description('列出镜像')
1389
+ .action(() => selectAction('images', { imageList: true }));
1390
+
1391
+ const removeCommand = program.command('rm <name>').helpGroup('容器与镜像:').description('删除指定容器');
1392
+ removeCommand
1393
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1394
+ .action((name, options) => selectAction('rm', { ...options, contName: name }));
1395
+
1396
+ const buildCommand = program.command('build').helpGroup('容器与镜像:').description('构建沙箱镜像');
1189
1397
  buildCommand
1190
1398
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1191
1399
  .option('--in, --image-name <name>', '指定镜像名称')
1192
- .option('--iv, --image-ver <version>', '指定镜像版本 (格式: x.y.z-后缀,如 1.7.4-common)')
1400
+ .option('--iv, --image-ver <version>', `指定镜像版本 (格式: x.y.z-后缀,如 ${IMAGE_VERSION_HELP_EXAMPLE})`)
1193
1401
  .option('--update-agents', '仅更新已有镜像内 Agent CLI 到 latest (Claude/Codex/Gemini/OpenCode)')
1194
1402
  .option('--yes', '所有提示自动确认 (用于CI/脚本)');
1195
1403
  appendArrayOption(buildCommand, '--iba, --image-build-arg <arg>', '构建镜像时传参给dockerfile (可多次使用)');
1196
1404
  buildCommand.action(options => selectAction('build', options));
1197
1405
 
1198
- const removeCommand = program.command('rm <name>').description('删除指定容器');
1199
- removeCommand
1200
- .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1201
- .action((name, options) => selectAction('rm', { ...options, contName: name }));
1202
-
1203
- program.command('ps')
1204
- .description('列举容器')
1205
- .action(() => selectAction('ps', { contList: true }));
1206
-
1207
- program.command('images')
1208
- .description('列举镜像')
1209
- .action(() => selectAction('images', { imageList: true }));
1406
+ program.command('prune')
1407
+ .helpGroup('容器与镜像:')
1408
+ .description('清理悬空镜像')
1409
+ .action(() => selectAction('prune', { imageRemove: true }));
1210
1410
 
1211
- const serveCommand = program.command('serve [listen]').description('启动网页交互服务 (默认 127.0.0.1:3000)');
1411
+ program.command('podman [args...]')
1412
+ .helpGroup('容器与镜像:')
1413
+ .description('用私有 Podman 执行命令(参数原样传入)')
1414
+ .helpOption(false)
1415
+ .allowUnknownOption()
1416
+ .passThroughOptions()
1417
+ .action(args => selectAction('podman', { podmanArgs: args || [] }));
1418
+
1419
+ // 网页服务与插件
1420
+ const serveCommand = program.command('serve [listen]').helpGroup('网页服务与插件:').description('启动网页服务 (默认 127.0.0.1:3000)');
1421
+ serveCommand.addHelpText('after', `
1422
+ 示例:
1423
+ ${MANYOYO_NAME} serve 127.0.0.1:3000 启动本机网页服务
1424
+ ${MANYOYO_NAME} serve 127.0.0.1:3000 -d 后台启动;未设密码时会打印本次随机密码
1425
+ ${MANYOYO_NAME} serve 0.0.0.0:3000 -U admin -P <密码> -d 后台启动并监听全部网卡
1426
+ ${MANYOYO_NAME} serve 0.0.0.0:3000 -U admin -P <密码> -d --restart 重启指定后台网页服务
1427
+ `);
1212
1428
  applyRunStyleOptions(serveCommand, { includeRmOnExit: false, includeWebAuthOptions: true });
1213
1429
  serveCommand.option('-d, --detach', '后台启动网页服务并立即返回');
1214
1430
  serveCommand.option('--stop', '停止后台网页服务;必须显式传入 listen');
@@ -1222,10 +1438,15 @@ Notes:
1222
1438
  });
1223
1439
  });
1224
1440
 
1225
- const playwrightCommand = program.command('playwright').description('管理 playwright 插件服务(推荐)');
1441
+ const playwrightCommand = program.command('playwright').helpGroup('网页服务与插件:').description('管理 Playwright 插件服务');
1442
+ playwrightCommand.addHelpText('after', `
1443
+ 示例:
1444
+ ${MANYOYO_NAME} playwright up mcp-host-headless 启动 MCP 宿主场景(默认/推荐)
1445
+ ${MANYOYO_NAME} playwright up cli-host-headless 启动 CLI 宿主场景(供容器内 playwright-cli 附着)
1446
+ `);
1226
1447
  registerPlaywrightAliasCommands(playwrightCommand);
1227
1448
 
1228
- const pluginCommand = program.command('plugin').description('管理 manyoyo 插件');
1449
+ const pluginCommand = program.command('plugin', { hidden: true }).description('管理 manyoyo 插件');
1229
1450
  pluginCommand.command('ls')
1230
1451
  .description('列出可用插件与启用场景')
1231
1452
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
@@ -1237,67 +1458,10 @@ Notes:
1237
1458
  const pluginPlaywrightCommand = pluginCommand.command('playwright').description('管理 playwright 插件服务');
1238
1459
  registerPlaywrightAliasCommands(pluginPlaywrightCommand);
1239
1460
 
1240
- const configCommand = program.command('config').description('查看解析后的配置或命令');
1241
- const configShowCommand = configCommand.command('show').description('显示最终生效配置并退出');
1242
- applyRunStyleOptions(configShowCommand, { includeRmOnExit: false, includeServePreview: true });
1243
- enableShellSuffixPassThrough(configShowCommand);
1244
- configShowCommand.action((options, command) => {
1245
- validateShellSuffixPassThroughArgs(command);
1246
- const finalOptions = {
1247
- ...options,
1248
- showConfig: true
1249
- };
1250
- if (options.serve !== undefined) {
1251
- finalOptions.server = options.serve;
1252
- finalOptions.serverUser = options.user;
1253
- finalOptions.serverPass = options.pass;
1254
- }
1255
- selectAction('config-show', finalOptions);
1256
- });
1257
-
1258
- const configRunCommand = configCommand.command('command').description('显示将执行的 docker run 命令并退出');
1259
- applyRunStyleOptions(configRunCommand, { includeRmOnExit: false });
1260
- enableShellSuffixPassThrough(configRunCommand);
1261
- configRunCommand.action((options, command) => {
1262
- validateShellSuffixPassThroughArgs(command);
1263
- selectAction('config-command', options);
1264
- });
1265
-
1266
- const initCommand = program.command('init [agents]').description('初始化 Agent 配置到 ~/.manyoyo');
1267
- initCommand
1268
- .option('--yes', '所有提示自动确认 (用于CI/脚本)')
1269
- .action((agents, options) => selectAction('init', { ...options, initConfig: agents === undefined ? 'all' : agents }));
1270
-
1271
- program.command('doctor')
1272
- .description('诊断容器运行时、镜像、配置、Agent、模式、插件和端口')
1273
- .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1274
- .option('--port <port>', '检查指定监听端口')
1275
- .option('--json', '以 JSON 输出稳定诊断结果')
1276
- .action(options => selectAction('doctor', { ...options, doctor: true }));
1277
-
1278
- program.command('update')
1279
- .description('更新 MANYOYO(若检测为本地 file 安装则跳过)')
1280
- .action(() => selectAction('update', { update: true }));
1281
-
1282
- program.command('install <name>')
1283
- .description(`安装 ${MANYOYO_NAME} 命令 (docker-cli-plugin)`)
1284
- .action(name => selectAction('install', { install: name }));
1285
-
1286
- program.command('prune')
1287
- .description('清理悬空镜像和 <none> 镜像')
1288
- .action(() => selectAction('prune', { imageRemove: true }));
1289
-
1290
- // Docker CLI plugin metadata check
1291
- if (maybeHandleDockerPluginMetadata(process.argv)) {
1292
- process.exit(0);
1293
- }
1294
-
1295
- // Docker CLI plugin mode - remove first arg if running as plugin
1296
- normalizeDockerPluginArgv(process.argv);
1297
-
1298
- // No args: show help instead of starting container
1299
- if (process.argv.length <= 2) {
1300
- program.help();
1461
+ // No args (or only --headless / --gui): start (or reuse) the local web app; open the browser already logged in unless headless
1462
+ const launcherArgs = parseLauncherArgs(process.argv.slice(2));
1463
+ if (launcherArgs) {
1464
+ await runAppLauncher({ force: launcherArgs.force });
1301
1465
  }
1302
1466
 
1303
1467
  // Pre-handle -x/--shell-full: treat all following args as a single command
@@ -1330,16 +1494,58 @@ Notes:
1330
1494
  throw new Error('serve --stop 与 --restart 不能同时使用');
1331
1495
  }
1332
1496
 
1333
- const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin', 'doctor']);
1497
+ UPDATE_CHECK_ENABLED = config.updateCheck !== false;
1498
+ MIRRORS = normalizeMirrors(config.mirrors);
1499
+ const noDockerActions = new Set(['init', 'update', 'config-show', 'plugin', 'doctor', 'uninstall', 'podman', 'setup']);
1334
1500
  if (isServerStopMode) {
1335
1501
  noDockerActions.add('serve');
1336
1502
  }
1337
1503
  if (!noDockerActions.has(selectedAction)) {
1338
- ensureDocker();
1504
+ await ensureDocker(config.containerRuntime, { deferHeal: selectedAction === 'serve' });
1339
1505
  }
1340
1506
 
1341
1507
  if (options.update) {
1342
- updateManyoyo();
1508
+ await updateManyoyo(options, config);
1509
+ process.exit(0);
1510
+ }
1511
+
1512
+ 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);
1529
+ }
1530
+
1531
+ if (selectedAction === 'podman') {
1532
+ process.exit(runPodmanCommand(options.podmanArgs, { commandName: MANYOYO_NAME }));
1533
+ }
1534
+
1535
+ if (selectedAction === 'uninstall') {
1536
+ await runUninstall({
1537
+ yes: yesMode,
1538
+ ask: askQuestion,
1539
+ log: line => console.log(line),
1540
+ selectExternalRuntime: () => {
1541
+ try {
1542
+ const selected = selectContainerRuntime({ configured: config.containerRuntime });
1543
+ return selected.source === 'private-podman' ? null : selected;
1544
+ } catch (error) {
1545
+ return null;
1546
+ }
1547
+ }
1548
+ });
1343
1549
  process.exit(0);
1344
1550
  }
1345
1551
 
@@ -1483,6 +1689,8 @@ Notes:
1483
1689
  worktreeRepoRoot: resolvedRuntime.worktreeRepoRoot,
1484
1690
  worktreeMainRepoRoot: resolvedRuntime.worktreeMainRepoRoot,
1485
1691
  containerMode: contModeValue || "",
1692
+ containerRuntime: config.containerRuntime || "auto",
1693
+ mirrors: MIRRORS,
1486
1694
  shellPrefix: EXEC_COMMAND_PREFIX.trim(),
1487
1695
  shell: EXEC_COMMAND || "",
1488
1696
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
@@ -1525,8 +1733,13 @@ Notes:
1525
1733
  const portStatus = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
1526
1734
  ? await checkPortAvailability(parsedPort)
1527
1735
  : undefined;
1528
- const report = runDoctorChecks({
1529
- runCommand: runCmd,
1736
+ let doctorRuntime = null;
1737
+ let report = await runDoctorChecks({
1738
+ selectRuntime: () => {
1739
+ doctorRuntime = selectContainerRuntime({ configured: config.containerRuntime });
1740
+ return doctorRuntime;
1741
+ },
1742
+ runCommand: runRuntimeCommand,
1530
1743
  configExists: fs.existsSync(getManyoyoConfigPath()),
1531
1744
  imageName: IMAGE_NAME,
1532
1745
  imageVersion: IMAGE_VERSION,
@@ -1535,11 +1748,49 @@ Notes:
1535
1748
  pluginConfig: config.plugins,
1536
1749
  portStatus
1537
1750
  });
1751
+ if (options.fix) {
1752
+ report = await applyDoctorFixes(report, {
1753
+ startRuntime: async () => {
1754
+ const result = await ensureRuntimeReady({ runtime: doctorRuntime, run: runRuntimeCommand });
1755
+ return { fixed: result.status === 'started' || result.status === 'ready', message: result.message };
1756
+ },
1757
+ pullImage: async () => {
1758
+ const image = `${IMAGE_NAME}:${IMAGE_VERSION}`;
1759
+ try {
1760
+ console.error(`正在拉取 ${image} ...`);
1761
+ await pullImageProcess({
1762
+ command: doctorRuntime.command,
1763
+ env: mergeRuntimeEnv(doctorRuntime.env),
1764
+ imageRef: image,
1765
+ onOutput: text => process.stderr.write(text)
1766
+ });
1767
+ } catch (e) {
1768
+ throw new Error(`拉取 ${image} 失败;可执行 ${MANYOYO_NAME} build --iv ${IMAGE_VERSION} 本地构建`);
1769
+ }
1770
+ return { fixed: true, message: `已拉取 ${image}` };
1771
+ },
1772
+ createConfig: () => {
1773
+ const result = syncGlobalImageVersion(IMAGE_VERSION);
1774
+ return { fixed: result.reason === 'created', message: result.reason === 'created' ? `已生成 ${result.path}` : `未生成配置: ${result.reason}` };
1775
+ },
1776
+ suggestPort: async () => {
1777
+ for (let candidate = parsedPort + 1; candidate <= Math.min(parsedPort + 50, 65535); candidate += 1) {
1778
+ if (await checkPortAvailability(candidate) === 'available') {
1779
+ return { fixed: false, message: `建议使用空闲端口 ${candidate}` };
1780
+ }
1781
+ }
1782
+ return { fixed: false, message: '附近没有空闲端口,请手动指定' };
1783
+ }
1784
+ });
1785
+ }
1538
1786
  if (options.json) {
1539
1787
  console.log(JSON.stringify(report, null, 4));
1540
1788
  } else {
1541
1789
  report.checks.forEach(check => {
1542
1790
  console.log(`[${check.status.toUpperCase()}] ${check.code}: ${check.summary}${check.action ? ` (${check.action})` : ''}`);
1791
+ if (check.fix && check.fix.attempted) {
1792
+ console.log(` ${check.fix.fixed ? '已修复' : '未修复'}: ${check.fix.message}`);
1793
+ }
1543
1794
  });
1544
1795
  }
1545
1796
  process.exit(report.ok ? 0 : 1);
@@ -1548,7 +1799,6 @@ Notes:
1548
1799
  if (isPsMode) { getContList(); process.exit(0); }
1549
1800
  if (isImagesMode) { getImageList(); process.exit(0); }
1550
1801
  if (isPruneMode) { pruneDanglingImages(); process.exit(0); }
1551
- if (selectedAction === 'install') { installManyoyo(options.install); process.exit(0); }
1552
1802
 
1553
1803
  return {
1554
1804
  yesMode,
@@ -1581,6 +1831,7 @@ function createRuntimeContext(modeState = {}) {
1581
1831
  contModeArgs: CONT_MODE_ARGS,
1582
1832
  containerExtraArgs: CONTAINER_EXTRA_ARGS,
1583
1833
  containerEnvs: CONTAINER_ENVS,
1834
+ mirrors: MIRRORS,
1584
1835
  firstContainerEnvs: FIRST_CONTAINER_ENVS,
1585
1836
  containerVolumes: CONTAINER_VOLUMES,
1586
1837
  containerPorts: CONTAINER_PORTS,
@@ -1816,8 +2067,9 @@ function relaunchServeDetached(runtime) {
1816
2067
  /**
1817
2068
  * 等待容器就绪(使用指数退避算法)
1818
2069
  * @param {string} containerName - 容器名称
2070
+ * @param {{throwOnFailure?: boolean}} [options] serve 路径传 true:失败抛错而不是 process.exit,调用方才能清理容器
1819
2071
  */
1820
- async function waitForContainerReady(containerName) {
2072
+ async function waitForContainerReady(containerName, options = {}) {
1821
2073
  const MAX_RETRIES = CONFIG.CONTAINER_READY_MAX_RETRIES;
1822
2074
  let retryDelay = CONFIG.CONTAINER_READY_INITIAL_DELAY;
1823
2075
 
@@ -1830,6 +2082,9 @@ async function waitForContainerReady(containerName) {
1830
2082
  }
1831
2083
 
1832
2084
  if (status === 'exited') {
2085
+ if (options.throwOnFailure) {
2086
+ throw Object.assign(new Error('容器启动后立即退出'), { containerExited: true });
2087
+ }
1833
2088
  console.log(`${RED}⚠️ 错误: 容器启动后立即退出。${NC}`);
1834
2089
  dockerExecArgs(['logs', containerName], { stdio: 'inherit' });
1835
2090
  process.exit(1);
@@ -1838,11 +2093,15 @@ async function waitForContainerReady(containerName) {
1838
2093
  await sleep(retryDelay);
1839
2094
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1840
2095
  } catch (e) {
2096
+ if (e && e.containerExited) throw e;
1841
2097
  await sleep(retryDelay);
1842
2098
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1843
2099
  }
1844
2100
  }
1845
2101
 
2102
+ if (options.throwOnFailure) {
2103
+ throw new Error('容器启动超时');
2104
+ }
1846
2105
  console.log(`${RED}⚠️ 错误: 容器启动超时。${NC}`);
1847
2106
  process.exit(1);
1848
2107
  }
@@ -1875,7 +2134,7 @@ function executeFirstCommand(runtime) {
1875
2134
  '-c',
1876
2135
  firstCommand
1877
2136
  ];
1878
- const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit' });
2137
+ const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit', env: DOCKER_ENV });
1879
2138
  if (firstExecResult.error) {
1880
2139
  throw firstExecResult.error;
1881
2140
  }
@@ -1908,6 +2167,8 @@ async function createNewContainer(runtime) {
1908
2167
  process.exit(0);
1909
2168
  }
1910
2169
 
2170
+ await ensureRunImage(runtime);
2171
+
1911
2172
  // 使用数组参数执行命令(安全方式)
1912
2173
  try {
1913
2174
  const args = buildDockerRunArgs(runtime);
@@ -1920,6 +2181,13 @@ async function createNewContainer(runtime) {
1920
2181
  // Wait for container to be ready
1921
2182
  await waitForContainerReady(runtime.containerName);
1922
2183
 
2184
+ applyAptMirror({
2185
+ dockerExecArgs,
2186
+ containerName: runtime.containerName,
2187
+ mirrors: runtime.mirrors,
2188
+ warn: message => console.warn(`${YELLOW}⚠️ ${message}${NC}`)
2189
+ });
2190
+
1923
2191
  // Run one-time bootstrap command for newly created containers only.
1924
2192
  executeFirstCommand(runtime);
1925
2193
 
@@ -1942,6 +2210,7 @@ function buildDockerRunArgs(runtime) {
1942
2210
  containerEnvs: runtime.containerEnvs,
1943
2211
  containerVolumes: runtime.containerVolumes,
1944
2212
  containerPorts: runtime.containerPorts,
2213
+ mirrors: runtime.mirrors,
1945
2214
  defaultCommand: runtime.execCommand
1946
2215
  });
1947
2216
  }
@@ -2017,9 +2286,9 @@ function executeInContainer(runtime, defaultCommand) {
2017
2286
 
2018
2287
  // Execute command in container
2019
2288
  if (runtime.execCommand) {
2020
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash', '-c', runtime.execCommand], { stdio: 'inherit' });
2289
+ spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash', '-c', runtime.execCommand], { stdio: 'inherit', env: DOCKER_ENV });
2021
2290
  } else {
2022
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash'], { stdio: 'inherit' });
2291
+ spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash'], { stdio: 'inherit', env: DOCKER_ENV });
2023
2292
  }
2024
2293
  }
2025
2294
 
@@ -2080,6 +2349,38 @@ async function handlePostExit(runtime, defaultCommand) {
2080
2349
  }
2081
2350
  }
2082
2351
 
2352
+ async function runAppLauncher({ force = null, exit = true } = {}) {
2353
+ try {
2354
+ const { headless } = detectHeadless({ force });
2355
+ await launchApp({
2356
+ headless,
2357
+ authUser: String(readManyoyoConfig().config.serverUser || '').trim() || 'admin',
2358
+ statePath: getAppStatePath(),
2359
+ isProcessRunning,
2360
+ spawnServe: port => {
2361
+ // 登录走一次性令牌;未配置密码时 serve 自己生成随机密码(标记为自动生成,向导据此让用户设置)
2362
+ const child = spawn(process.argv[0], [process.argv[1], 'serve', `127.0.0.1:${port}`], {
2363
+ detached: true,
2364
+ stdio: 'ignore'
2365
+ });
2366
+ child.unref();
2367
+ return child;
2368
+ },
2369
+ issueToken: () => issueLoginToken(getLoginTokenDir()),
2370
+ open: url => openBrowser(url),
2371
+ log: line => console.log(line),
2372
+ logPathHint: buildManyoyoLogPath('serve').path,
2373
+ commandName: MANYOYO_NAME
2374
+ });
2375
+ } catch (e) {
2376
+ console.error(`${RED}${e.message}${NC}`);
2377
+ if (!exit) return false;
2378
+ process.exit(1);
2379
+ }
2380
+ if (!exit) return true;
2381
+ process.exit(0);
2382
+ }
2383
+
2083
2384
  async function runWebServerMode(runtime) {
2084
2385
  if (!runtime.serverAuthUser || !runtime.serverAuthPass) {
2085
2386
  ensureWebServerAuthCredentials();
@@ -2088,6 +2389,48 @@ async function runWebServerMode(runtime) {
2088
2389
  runtime.serverAuthPassAuto = SERVER_AUTH_PASS_AUTO;
2089
2390
  }
2090
2391
 
2392
+ const updateChecker = createUpdateChecker({
2393
+ currentVersion: require('../package.json').version,
2394
+ installMode: appUpdate.detectInstallMode({ scriptPath: __filename }).mode,
2395
+ enabled: UPDATE_CHECK_ENABLED,
2396
+ fetchLatest: () => appUpdate.fetchLatestRelease()
2397
+ });
2398
+
2399
+ const needRuntimeHeal = !isRuntimeProven();
2400
+ if (needRuntimeHeal) {
2401
+ RUNTIME_STATE.status = 'starting';
2402
+ RUNTIME_STATE.message = '正在检查容器环境';
2403
+ }
2404
+
2405
+ // 自愈失败/超时不是终态:用户手动修好(或点向导里的“重试”)后可以再来一次
2406
+ let runtimeHealing = false;
2407
+ function startServeRuntimeHeal() {
2408
+ if (runtimeHealing) return false;
2409
+ runtimeHealing = true;
2410
+ RUNTIME_STATE.status = 'starting';
2411
+ RUNTIME_STATE.message = '正在检查容器环境';
2412
+ healContainerRuntime(state => {
2413
+ RUNTIME_STATE.status = state.status;
2414
+ RUNTIME_STATE.message = state.message;
2415
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
2416
+ }, { async: true }).then(result => {
2417
+ if (result.status === 'ready' || result.status === 'started') {
2418
+ RUNTIME_STATE.status = 'ready';
2419
+ RUNTIME_STATE.message = '';
2420
+ } else {
2421
+ RUNTIME_STATE.status = 'failed';
2422
+ RUNTIME_STATE.message = result.message;
2423
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
2424
+ }
2425
+ }).catch(error => {
2426
+ RUNTIME_STATE.status = 'failed';
2427
+ RUNTIME_STATE.message = String(error && error.message || error);
2428
+ }).finally(() => {
2429
+ runtimeHealing = false;
2430
+ });
2431
+ return true;
2432
+ }
2433
+
2091
2434
  const serverHandle = await startWebServer({
2092
2435
  serverHost: runtime.serverHost,
2093
2436
  serverPort: runtime.serverPort,
@@ -2096,6 +2439,31 @@ async function runWebServerMode(runtime) {
2096
2439
  authPassAuto: runtime.serverAuthPassAuto,
2097
2440
  serveTitle: runtime.serveTitle,
2098
2441
  dockerCmd: DOCKER_CMD,
2442
+ dockerEnv: DOCKER_ENV,
2443
+ runtimeState: RUNTIME_STATE,
2444
+ retryRuntimeHeal: () => (needRuntimeHeal ? startServeRuntimeHeal() : false),
2445
+ autoPullImage: true,
2446
+ importState: () => readImportState(),
2447
+ updateInfo: () => updateChecker.getInfo(),
2448
+ loginTokenDir: getLoginTokenDir(),
2449
+ doctorCheck: async () => runDoctorChecks({
2450
+ selectRuntime: () => CONTAINER_RUNTIME,
2451
+ runCommand: runRuntimeCommandAsync,
2452
+ configExists: fs.existsSync(getManyoyoConfigPath()),
2453
+ imageName: runtime.imageName,
2454
+ imageVersion: runtime.imageVersion,
2455
+ containerMode: 'common',
2456
+ pluginConfig: {}
2457
+ }),
2458
+ doctorStartRuntime: async () => {
2459
+ const result = await ensureRuntimeReady({ runtime: CONTAINER_RUNTIME, run: runRuntimeCommandAsync });
2460
+ const fixed = result.status === 'started' || result.status === 'ready';
2461
+ if (fixed) {
2462
+ RUNTIME_STATE.status = 'ready';
2463
+ RUNTIME_STATE.message = '';
2464
+ }
2465
+ return { fixed, message: result.message };
2466
+ },
2099
2467
  hostPath: runtime.hostPath,
2100
2468
  containerPath: runtime.containerPath,
2101
2469
  imageName: runtime.imageName,
@@ -2115,6 +2483,7 @@ async function runWebServerMode(runtime) {
2115
2483
  getContainerStatus,
2116
2484
  waitForContainerReady,
2117
2485
  dockerExecArgs,
2486
+ dockerExecAsync: (args, options = {}) => runCommandAsync(DOCKER_CMD, args, { env: DOCKER_ENV, timeout: options.timeout }),
2118
2487
  showImagePullHint,
2119
2488
  removeContainer,
2120
2489
  webHistoryDir: path.join(os.homedir(), '.manyoyo', 'web-history'),
@@ -2129,6 +2498,9 @@ async function runWebServerMode(runtime) {
2129
2498
  logger: runtime.logger
2130
2499
  });
2131
2500
  writeServePidFile(runtime, serverHandle);
2501
+ if (needRuntimeHeal) {
2502
+ startServeRuntimeHeal();
2503
+ }
2132
2504
  return serverHandle;
2133
2505
  }
2134
2506
 
@@ -2190,6 +2562,7 @@ async function main() {
2190
2562
  yesMode: Boolean(modeState.yesMode),
2191
2563
  updateAgents: Boolean(modeState.updateAgents),
2192
2564
  dockerCmd: DOCKER_CMD,
2565
+ dockerEnv: DOCKER_ENV,
2193
2566
  rootDir: path.join(__dirname, '..'),
2194
2567
  loadConfig,
2195
2568
  runCmd,