@xcanwin/manyoyo 7.1.5 → 8.0.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/bin/manyoyo.js CHANGED
@@ -11,11 +11,26 @@ const { Command } = 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,7 +50,8 @@ 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 IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
53
+ const GLOBAL_CONFIG_IMAGE_VERSION = String(readManyoyoConfig().config.imageVersion || '').trim();
54
+ const IMAGE_VERSION_HELP_EXAMPLE = GLOBAL_CONFIG_IMAGE_VERSION || IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
37
55
 
38
56
  // Helper function to format date like bash $(date +%m%d-%H%M)
39
57
  function formatDate() {
@@ -46,6 +64,10 @@ function formatDate() {
46
64
  }
47
65
 
48
66
  function detectCommandName() {
67
+ // 离线安装器生成的 manyoyo / my 包装脚本都指向同一个 manyoyo.js,用环境变量区分显示的命令名
68
+ const fromEnv = String(process.env.MANYOYO_COMMAND_NAME || '').trim();
69
+ if (/^[A-Za-z][A-Za-z0-9_-]*$/.test(fromEnv)) return fromEnv;
70
+
49
71
  const rawArgv1 = process.argv[1] || '';
50
72
  const baseName = path.basename(rawArgv1).replace(/\.(cjs|mjs|js)$/i, '');
51
73
 
@@ -67,7 +89,7 @@ const CONFIG = {
67
89
  let CONTAINER_NAME = `my-${formatDate()}`;
68
90
  let HOST_PATH = process.cwd();
69
91
  let CONTAINER_PATH = HOST_PATH;
70
- let IMAGE_NAME = "localhost/xcanwin/manyoyo";
92
+ let IMAGE_NAME = "ghcr.io/xcanwin/manyoyo";
71
93
  let IMAGE_VERSION = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
72
94
  let EXEC_COMMAND = "";
73
95
  let EXEC_COMMAND_PREFIX = "";
@@ -104,6 +126,16 @@ const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)
104
126
 
105
127
  // Docker command (will be set by ensure_docker)
106
128
  let DOCKER_CMD = 'docker';
129
+ // 仅运行时子进程使用的完整 env(私有 Podman 才有值),不要写回 process.env
130
+ let DOCKER_ENV;
131
+ let CONTAINER_RUNTIME = null;
132
+ // 全局配置 updateCheck(默认 true):serve 是否每天检查一次新版本
133
+ let UPDATE_CHECK_ENABLED = true;
134
+ // 全局配置 mirrors(apt/npm/pip 软件源,空 = 官方默认):容器创建时在容器层生效,镜像不变
135
+ let MIRRORS = { apt: '', npm: '', pip: '' };
136
+ // serve 的容器环境状态,供 GET /api/system/runtime 读取
137
+ const RUNTIME_STATE = { status: 'ready', message: '' };
138
+ const DOCKER_DAEMON_ERROR_CODES = new Set(['PODMAN_MACHINE_UNAVAILABLE', 'DOCKER_DAEMON_UNAVAILABLE', 'PORT_IN_USE']);
107
139
  const SUPPORTED_INIT_AGENTS = ['claude', 'codex', 'gemini', 'opencode'];
108
140
 
109
141
  function sleep(ms) {
@@ -311,6 +343,9 @@ function installServeProcessDiagnostics(logger) {
311
343
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
312
344
  * @property {string} [yolo] - YOLO 模式
313
345
  * @property {string} [containerMode] - 容器模式
346
+ * @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
347
+ * @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
348
+ * @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
314
349
  * @property {number} [cacheTTL] - 缓存过期天数
315
350
  * @property {string} [nodeMirror] - Node.js 镜像源
316
351
  */
@@ -671,17 +706,39 @@ function setContMode(mode) {
671
706
  }
672
707
  }
673
708
 
709
+ // 本地没有镜像时自动拉取(终端显示拉取进度),失败给出原因与下一步
710
+ async function ensureRunImage(runtime) {
711
+ const imageRef = `${runtime.imageName}:${runtime.imageVersion}`;
712
+ // 离线安装器还在后台导入镜像时先等它,避免重复去仓库拉
713
+ await waitForImport({ onWait: state => console.log(`${YELLOW}⏳ ${state.message},等待完成...${NC}`) });
714
+ await ensureImagePresent({
715
+ imageRef,
716
+ command: DOCKER_CMD,
717
+ isPresent: () => {
718
+ try {
719
+ dockerExecArgs(['image', 'inspect', imageRef], { stdio: 'pipe' });
720
+ return true;
721
+ } catch (e) {
722
+ return false;
723
+ }
724
+ },
725
+ pull: () => pullImageProcess({
726
+ command: DOCKER_CMD,
727
+ env: DOCKER_ENV,
728
+ imageRef,
729
+ onOutput: (text, stream) => (stream === 'stderr' ? process.stderr : process.stdout).write(text)
730
+ }),
731
+ onStart: () => console.log(`${YELLOW}⏬ 本地没有镜像 ${imageRef},正在拉取...${NC}`)
732
+ });
733
+ }
734
+
674
735
  function showImagePullHint(err) {
675
- const stderr = err && err.stderr ? err.stderr.toString() : '';
676
- const stdout = err && err.stdout ? err.stdout.toString() : '';
677
- const message = err && err.message ? err.message : '';
678
- const combined = `${message}\n${stderr}\n${stdout}`;
679
- if (!/localhost\/v2|pinging container registry localhost|connection refused|dial tcp .*:443/i.test(combined)) {
736
+ const info = describeError(getCommandFailureText(err), { imageRef: `${IMAGE_NAME}:${IMAGE_VERSION}` });
737
+ if (!info || !['IMAGE_PULL_FAILED', 'IMAGE_NOT_FOUND'].includes(info.code)) {
680
738
  return;
681
739
  }
682
- const image = `${IMAGE_NAME}:${IMAGE_VERSION}`;
683
- console.log(`${YELLOW}💡 提示: 本地未找到镜像 ${image},并且从 localhost 注册表拉取失败。${NC}`);
684
- console.log(`${YELLOW} 你可以: (1) 更新 ~/.manyoyo/manyoyo.json 的 imageVersion。 (2) 或先执行 ${MANYOYO_NAME} build --iv <x.y.z-后缀> 构建镜像。${NC}`);
740
+ console.log(`${YELLOW}💡 提示: ${info.reason}${NC}`);
741
+ console.log(`${YELLOW} ${info.action}${NC}`);
685
742
  }
686
743
 
687
744
  function getCommandFailureText(err) {
@@ -691,25 +748,6 @@ function getCommandFailureText(err) {
691
748
  return `${message}\n${stderr}\n${stdout}`;
692
749
  }
693
750
 
694
- function getContainerRuntimeUnavailableHint(command, err) {
695
- const text = getCommandFailureText(err);
696
- if (/Cannot connect to Podman|unable to connect to Podman socket|podman machine start|podman system connection/i.test(text)) {
697
- return [
698
- '',
699
- `提示: 当前 ${command} 命令正在连接 Podman machine,但连接不可用。`,
700
- '请先在宿主机执行: podman machine start'
701
- ].join('\n');
702
- }
703
- if (/Cannot connect to the Docker daemon|docker daemon is not running|Is the docker daemon running/i.test(text)) {
704
- return [
705
- '',
706
- `提示: 当前 ${command} 命令无法连接容器运行时。`,
707
- '请先启动 Docker Desktop / Docker daemon,或确认 Podman machine 已启动。'
708
- ].join('\n');
709
- }
710
- return '';
711
- }
712
-
713
751
  function runCmd(cmd, args, options = {}) {
714
752
  const result = spawnSync(cmd, args, { encoding: 'utf-8', ...options });
715
753
  if (result.error) {
@@ -738,11 +776,12 @@ function checkPortAvailability(port) {
738
776
 
739
777
  function dockerExecArgs(args, options = {}) {
740
778
  try {
741
- return runCmd(DOCKER_CMD, args, options);
779
+ return runCmd(DOCKER_CMD, args, { env: DOCKER_ENV, ...options });
742
780
  } catch (e) {
743
- const hint = getContainerRuntimeUnavailableHint(DOCKER_CMD, e);
744
- if (hint && e && e.message && !e.message.includes(hint)) {
745
- e.message = `${e.message}${hint}`;
781
+ const info = describeError(getCommandFailureText(e), { command: DOCKER_CMD });
782
+ if (info && DOCKER_DAEMON_ERROR_CODES.has(info.code) && e && e.message) {
783
+ const hint = `\n提示: ${info.reason}\n${info.action}`;
784
+ if (!e.message.includes(hint)) e.message = `${e.message}${hint}`;
746
785
  }
747
786
  throw e;
748
787
  }
@@ -763,19 +802,51 @@ function removeContainer(name) {
763
802
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${GREEN}✅ 已彻底删除。${NC}`);
764
803
  }
765
804
 
766
- function ensureDocker() {
767
- const commands = ['docker', 'podman'];
768
- for (const cmd of commands) {
769
- try {
770
- runCmd(cmd, ['--version'], { stdio: 'pipe' });
771
- DOCKER_CMD = cmd;
772
- return true;
773
- } catch (e) {
774
- // Try next command
775
- }
805
+ // 运行时子进程执行器:env 只是增量,合并后仅传给这个子进程
806
+ function runRuntimeCommand(command, args, options = {}) {
807
+ return runCmd(command, args, {
808
+ stdio: 'pipe',
809
+ timeout: options.timeout,
810
+ env: mergeRuntimeEnv(options.env)
811
+ });
812
+ }
813
+
814
+ // serve 里的自愈用异步执行器:machine start 最长 120s,同步执行会冻住整个事件循环(向导进度、登录页都没响应)
815
+ function runRuntimeCommandAsync(command, args, options = {}) {
816
+ return runCommandAsync(command, args, { timeout: options.timeout, env: mergeRuntimeEnv(options.env) });
817
+ }
818
+
819
+ function healContainerRuntime(onStatus, options = {}) {
820
+ return ensureRuntimeReady({ runtime: CONTAINER_RUNTIME, run: options.async ? runRuntimeCommandAsync : runRuntimeCommand, onStatus });
821
+ }
822
+
823
+ // 选择阶段已确认 daemon 可用(info 通过)时无需再自愈
824
+ function isRuntimeProven() {
825
+ return /-daemon$/.test(CONTAINER_RUNTIME.source);
826
+ }
827
+
828
+ async function ensureDocker(configuredRuntime, options = {}) {
829
+ try {
830
+ CONTAINER_RUNTIME = selectContainerRuntime({ configured: configuredRuntime });
831
+ } catch (e) {
832
+ console.error(e.message);
833
+ process.exit(1);
776
834
  }
777
- console.error("docker/podman not found");
778
- process.exit(1);
835
+ DOCKER_CMD = CONTAINER_RUNTIME.command;
836
+ DOCKER_ENV = mergeRuntimeEnv(CONTAINER_RUNTIME.env);
837
+ if (options.deferHeal || isRuntimeProven()) {
838
+ return true;
839
+ }
840
+
841
+ const result = await healContainerRuntime(state => {
842
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
843
+ });
844
+ if (result.status === 'started') {
845
+ console.log(`${GREEN}✅ 容器环境已就绪${NC}`);
846
+ } else if (result.status !== 'ready') {
847
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
848
+ }
849
+ return true;
779
850
  }
780
851
 
781
852
  function installManyoyo(name) {
@@ -796,7 +867,130 @@ function installManyoyo(name) {
796
867
  process.exit(0);
797
868
  }
798
869
 
799
- function updateManyoyo() {
870
+ // 升级后让旧版本的后台服务退出(pid 必须确实是 manyoyo serve),下次执行 manyoyo 就会启动新版本
871
+ function stopBackgroundApp({ quiet = false } = {}) {
872
+ const pid = readPid(path.join(os.homedir(), '.manyoyo', 'serve', 'app.json'));
873
+ if (pid && pid !== process.pid && defaultIsManyoyoServe(pid) && defaultKill(pid)) {
874
+ if (!quiet) console.log(`${GREEN}✅ 已停止旧版本的后台服务 (pid ${pid}),下次执行 ${MANYOYO_NAME} 会启动新版本。${NC}`);
875
+ }
876
+ }
877
+
878
+ // 升级流程里准备容器运行时:和 ensureDocker 一样选择并(必要时)自愈,但没有运行时也不退出进程
879
+ async function prepareRuntimeForUpdate(configuredRuntime) {
880
+ try {
881
+ CONTAINER_RUNTIME = selectContainerRuntime({ configured: configuredRuntime });
882
+ } catch (error) {
883
+ return false;
884
+ }
885
+ DOCKER_CMD = CONTAINER_RUNTIME.command;
886
+ DOCKER_ENV = mergeRuntimeEnv(CONTAINER_RUNTIME.env);
887
+ if (!isRuntimeProven()) {
888
+ const result = await healContainerRuntime(state => {
889
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
890
+ });
891
+ if (result.status !== 'ready' && result.status !== 'started') {
892
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
893
+ return false;
894
+ }
895
+ }
896
+ return true;
897
+ }
898
+
899
+ async function updateOfflineInstall(mode, options, globalConfig) {
900
+ const { appRoot } = mode;
901
+ if (options.rollback) {
902
+ const result = appUpdate.rollbackApp({ appRoot });
903
+ console.log(`${GREEN}✅ 已回滚: ${result.from || '未知'} → ${result.to}${NC}`);
904
+ stopBackgroundApp();
905
+ return;
906
+ }
907
+
908
+ const installed = appUpdate.currentVersion(appRoot) || mode.version;
909
+ console.log(`${CYAN}🔄 当前版本: ${installed}${NC}`);
910
+ console.log(`${CYAN}🔄 正在查询最新版本...${NC}`);
911
+ const release = await appUpdate.fetchLatestRelease();
912
+ if (appUpdate.compareVersions(release.version, installed) <= 0) {
913
+ console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
914
+ return;
915
+ }
916
+ console.log(`${CYAN}⬇️ 发现新版本 ${release.version},只下载 manyoyo 本体(数十 MB)${NC}`);
917
+ const result = await appUpdate.installAppUpdate({ appRoot, release, targetOs: appUpdate.platformOs(), log: line => console.log(` ${line}`) });
918
+ console.log(`${GREEN}✅ 更新完成: ${installed} → ${result.version}(上一版本 ${result.previous || '无'} 已保留,可用 ${MANYOYO_NAME} update --rollback 回滚)${NC}`);
919
+
920
+ // Podman / VM 磁盘有变化只提示(需要新的完整包)
921
+ try {
922
+ const names = appUpdate.releaseAssetNames(release.version, appUpdate.arch(), appUpdate.platformOs());
923
+ if (release.assets[names.manifest]) {
924
+ const remote = JSON.parse(await appUpdate.fetchText(fetch, release.assets[names.manifest]));
925
+ const hint = appUpdate.describeRuntimeChange(appUpdate.readInstalledRecord(os.homedir()), remote);
926
+ if (hint) console.log(`${YELLOW}ℹ️ ${hint}${NC}`);
927
+ }
928
+ } catch (error) {
929
+ // 提示信息拿不到不影响升级
930
+ }
931
+
932
+ // 新版本要求的镜像不存在就拉取;仍用旧镜像的容器只列出来,不动
933
+ const imageName = String(globalConfig.imageName || 'ghcr.io/xcanwin/manyoyo');
934
+ const versionPolicy = resolveUpdateImageVersion({
935
+ configured: globalConfig.imageVersion,
936
+ previousDefault: require('../package.json').imageVersion,
937
+ nextDefault: result.manifest.imageVersion
938
+ });
939
+ const imageVersion = versionPolicy.imageVersion;
940
+ if (versionPolicy.advance) {
941
+ const synced = syncGlobalImageVersion(imageVersion);
942
+ console.log(`${CYAN}ℹ️ 配置里的 imageVersion(${globalConfig.imageVersion})是旧版本默认值,已随新版本更新为 ${imageVersion}${synced.updated ? '' : '(写入配置失败,请手动修改)'}${NC}`);
943
+ } else if (versionPolicy.pinned) {
944
+ console.log(`${YELLOW}ℹ️ 你在配置里固定了 imageVersion=${imageVersion},新版本默认是 ${result.manifest.imageVersion};如需使用新镜像请修改 ~/.manyoyo/manyoyo.json${NC}`);
945
+ }
946
+ if (imageVersion && await prepareRuntimeForUpdate(globalConfig.containerRuntime)) {
947
+ const imageRef = `${imageName}:${imageVersion}`;
948
+ try {
949
+ await ensureImagePresent({
950
+ imageRef,
951
+ command: DOCKER_CMD,
952
+ isPresent: () => {
953
+ try {
954
+ dockerExecArgs(['image', 'inspect', imageRef], { stdio: 'pipe' });
955
+ return true;
956
+ } catch (e) {
957
+ return false;
958
+ }
959
+ },
960
+ pull: () => pullImageProcess({ command: DOCKER_CMD, env: DOCKER_ENV, imageRef, onOutput: (text, stream) => (stream === 'stderr' ? process.stderr : process.stdout).write(text) }),
961
+ onStart: () => console.log(`${YELLOW}⏬ 新版本需要镜像 ${imageRef},正在拉取...${NC}`)
962
+ });
963
+ } catch (error) {
964
+ console.log(`${YELLOW}⚠️ 镜像还没准备好:${error.message}${NC}`);
965
+ }
966
+ const outdated = appUpdate.findOutdatedContainers({ run: runCmd, runtime: { command: DOCKER_CMD, env: DOCKER_ENV }, imageRef });
967
+ if (outdated.length > 0) {
968
+ console.log(`${YELLOW}ℹ️ 有 ${outdated.length} 个容器还在用旧镜像:${outdated.map(item => `${item.name}(${item.image})`).join('、')}${NC}`);
969
+ console.log(`${YELLOW} 它们可以继续使用;想换成新镜像,请新建容器(会话历史与凭据都在宿主机上,不会丢)。${NC}`);
970
+ }
971
+ }
972
+ stopBackgroundApp();
973
+ }
974
+
975
+ async function updateManyoyo(options = {}, globalConfig = {}) {
976
+ const mode = appUpdate.detectInstallMode({ scriptPath: __filename });
977
+ if (mode.mode === 'offline') {
978
+ try {
979
+ await updateOfflineInstall(mode, options, globalConfig);
980
+ } catch (error) {
981
+ if (error instanceof appUpdate.UpdateError) {
982
+ console.error(`${RED}❌ ${error.message}${NC}`);
983
+ process.exit(1);
984
+ }
985
+ throw error;
986
+ }
987
+ return;
988
+ }
989
+ if (options.rollback) {
990
+ console.error(`${RED}❌ --rollback 只适用于离线包安装;npm 安装的版本请用 npm install -g @xcanwin/manyoyo@<版本> 回退。${NC}`);
991
+ process.exit(1);
992
+ }
993
+
800
994
  let isLocalFileInstall = false;
801
995
  let currentVersion = 'unknown';
802
996
 
@@ -901,27 +1095,11 @@ function getImageList() {
901
1095
  }
902
1096
 
903
1097
  function pruneDanglingImages() {
904
- console.log(`\n${YELLOW}清理悬空镜像...${NC}`);
905
- dockerExecArgs(['image', 'prune', '-f'], { stdio: 'inherit' });
906
-
907
- // Remove remaining <none> images
908
- try {
909
- const imagesOutput = dockerExecArgs(['images', '-a', '--format', '{{.ID}} {{.Repository}}']);
910
- const noneImages = imagesOutput
911
- .split('\n')
912
- .filter(line => line.includes('<none>'))
913
- .map(line => line.split(' ')[0])
914
- .filter(id => id);
915
-
916
- if (noneImages.length > 0) {
917
- console.log(`${YELLOW}清理剩余的 <none> 镜像 (${noneImages.length} 个)...${NC}`);
918
- dockerExecArgs(['rmi', '-f', ...noneImages], { stdio: 'inherit' });
919
- }
920
- } catch (e) {
921
- // Ignore errors if no <none> images found
922
- }
923
-
924
- console.log(`${GREEN}✅ 清理完成${NC}`);
1098
+ pruneDanglingImagesSafely({
1099
+ dockerExecArgs,
1100
+ log: line => console.log(line),
1101
+ colors: { YELLOW, GREEN, NC }
1102
+ });
925
1103
  }
926
1104
 
927
1105
  function maybeHandleDockerPluginMetadata(argv) {
@@ -1262,6 +1440,11 @@ Notes:
1262
1440
  selectAction('config-command', options);
1263
1441
  });
1264
1442
 
1443
+ program.command('uninstall')
1444
+ .description('卸载离线包安装的 MANYOYO:停止服务,删除程序与 PATH 配置,用户数据逐项询问(--yes 不会删用户数据)')
1445
+ .option('--yes', '确认卸载程序本身,不再询问(不会删除配置、历史、日志、工作目录和外部运行时里的容器镜像)')
1446
+ .action(options => selectAction('uninstall', options));
1447
+
1265
1448
  const initCommand = program.command('init [agents]').description('初始化 Agent 配置到 ~/.manyoyo');
1266
1449
  initCommand
1267
1450
  .option('--yes', '所有提示自动确认 (用于CI/脚本)')
@@ -1272,11 +1455,24 @@ Notes:
1272
1455
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1273
1456
  .option('--port <port>', '检查指定监听端口')
1274
1457
  .option('--json', '以 JSON 输出稳定诊断结果')
1458
+ .option('--fix', '自动修复可修复项(启动容器环境、拉取镜像、生成默认配置,端口占用时给出建议端口)')
1275
1459
  .action(options => selectAction('doctor', { ...options, doctor: true }));
1276
1460
 
1277
1461
  program.command('update')
1278
- .description('更新 MANYOYO(若检测为本地 file 安装则跳过)')
1279
- .action(() => selectAction('update', { update: true }));
1462
+ .description('更新 MANYOYO(离线包安装只下载变化部分并保留上一版本;若检测为本地 file 安装则跳过)')
1463
+ .option('--rollback', '离线包安装:切回上一版本')
1464
+ .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback) }));
1465
+
1466
+ program.command('podman [args...]')
1467
+ .description('用 MANYOYO 的私有 Podman 执行命令(参数原样传给 podman);eval "$(manyoyo podman env)" 可在当前终端定义 podman 函数')
1468
+ .helpOption(false)
1469
+ .allowUnknownOption()
1470
+ .passThroughOptions()
1471
+ .action(args => selectAction('podman', { podmanArgs: args || [] }));
1472
+
1473
+ program.command('setup')
1474
+ .description('命令行配置向导(无头环境:SSH、没有图形界面):选 Agent、填 Key、设登录密码,可选软件源')
1475
+ .action(() => selectAction('setup', {}));
1280
1476
 
1281
1477
  program.command('install <name>')
1282
1478
  .description(`安装 ${MANYOYO_NAME} 命令 (docker-cli-plugin)`)
@@ -1294,9 +1490,10 @@ Notes:
1294
1490
  // Docker CLI plugin mode - remove first arg if running as plugin
1295
1491
  normalizeDockerPluginArgv(process.argv);
1296
1492
 
1297
- // No args: show help instead of starting container
1298
- if (process.argv.length <= 2) {
1299
- program.help();
1493
+ // No args (or only --headless / --gui): start (or reuse) the local web app; open the browser already logged in unless headless
1494
+ const launcherArgs = parseLauncherArgs(process.argv.slice(2));
1495
+ if (launcherArgs) {
1496
+ await runAppLauncher({ force: launcherArgs.force });
1300
1497
  }
1301
1498
 
1302
1499
  // Pre-handle -x/--shell-full: treat all following args as a single command
@@ -1329,16 +1526,58 @@ Notes:
1329
1526
  throw new Error('serve --stop 与 --restart 不能同时使用');
1330
1527
  }
1331
1528
 
1332
- const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin', 'doctor']);
1529
+ UPDATE_CHECK_ENABLED = config.updateCheck !== false;
1530
+ MIRRORS = normalizeMirrors(config.mirrors);
1531
+ const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin', 'doctor', 'uninstall', 'podman', 'setup']);
1333
1532
  if (isServerStopMode) {
1334
1533
  noDockerActions.add('serve');
1335
1534
  }
1336
1535
  if (!noDockerActions.has(selectedAction)) {
1337
- ensureDocker();
1536
+ await ensureDocker(config.containerRuntime, { deferHeal: selectedAction === 'serve' });
1338
1537
  }
1339
1538
 
1340
1539
  if (options.update) {
1341
- updateManyoyo();
1540
+ await updateManyoyo(options, config);
1541
+ process.exit(0);
1542
+ }
1543
+
1544
+ if (selectedAction === 'setup') {
1545
+ const interactive = Boolean(process.stdin.isTTY);
1546
+ const code = await runSetupCli({
1547
+ isTTY: interactive,
1548
+ prompter: interactive ? createPrompter() : null,
1549
+ configPath: getManyoyoConfigPath(),
1550
+ defaultWorkpath: path.join(os.homedir(), '.manyoyo', 'work'),
1551
+ validateHostPath: value => validateHostPathOrThrow(value),
1552
+ commandName: MANYOYO_NAME,
1553
+ log: line => console.log(line),
1554
+ // 新密码要让后台服务重新读配置:先停掉旧的,再按当前环境(有头 / 无头)启动
1555
+ afterSave: async () => {
1556
+ stopBackgroundApp({ quiet: true });
1557
+ await runAppLauncher({ exit: false });
1558
+ }
1559
+ });
1560
+ process.exit(code);
1561
+ }
1562
+
1563
+ if (selectedAction === 'podman') {
1564
+ process.exit(runPodmanCommand(options.podmanArgs, { commandName: MANYOYO_NAME }));
1565
+ }
1566
+
1567
+ if (selectedAction === 'uninstall') {
1568
+ await runUninstall({
1569
+ yes: yesMode,
1570
+ ask: askQuestion,
1571
+ log: line => console.log(line),
1572
+ selectExternalRuntime: () => {
1573
+ try {
1574
+ const selected = selectContainerRuntime({ configured: config.containerRuntime });
1575
+ return selected.source === 'private-podman' ? null : selected;
1576
+ } catch (error) {
1577
+ return null;
1578
+ }
1579
+ }
1580
+ });
1342
1581
  process.exit(0);
1343
1582
  }
1344
1583
 
@@ -1482,6 +1721,8 @@ Notes:
1482
1721
  worktreeRepoRoot: resolvedRuntime.worktreeRepoRoot,
1483
1722
  worktreeMainRepoRoot: resolvedRuntime.worktreeMainRepoRoot,
1484
1723
  containerMode: contModeValue || "",
1724
+ containerRuntime: config.containerRuntime || "auto",
1725
+ mirrors: MIRRORS,
1485
1726
  shellPrefix: EXEC_COMMAND_PREFIX.trim(),
1486
1727
  shell: EXEC_COMMAND || "",
1487
1728
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
@@ -1524,8 +1765,13 @@ Notes:
1524
1765
  const portStatus = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
1525
1766
  ? await checkPortAvailability(parsedPort)
1526
1767
  : undefined;
1527
- const report = runDoctorChecks({
1528
- runCommand: runCmd,
1768
+ let doctorRuntime = null;
1769
+ let report = await runDoctorChecks({
1770
+ selectRuntime: () => {
1771
+ doctorRuntime = selectContainerRuntime({ configured: config.containerRuntime });
1772
+ return doctorRuntime;
1773
+ },
1774
+ runCommand: runRuntimeCommand,
1529
1775
  configExists: fs.existsSync(getManyoyoConfigPath()),
1530
1776
  imageName: IMAGE_NAME,
1531
1777
  imageVersion: IMAGE_VERSION,
@@ -1534,11 +1780,49 @@ Notes:
1534
1780
  pluginConfig: config.plugins,
1535
1781
  portStatus
1536
1782
  });
1783
+ if (options.fix) {
1784
+ report = await applyDoctorFixes(report, {
1785
+ startRuntime: async () => {
1786
+ const result = await ensureRuntimeReady({ runtime: doctorRuntime, run: runRuntimeCommand });
1787
+ return { fixed: result.status === 'started' || result.status === 'ready', message: result.message };
1788
+ },
1789
+ pullImage: async () => {
1790
+ const image = `${IMAGE_NAME}:${IMAGE_VERSION}`;
1791
+ try {
1792
+ console.error(`正在拉取 ${image} ...`);
1793
+ await pullImageProcess({
1794
+ command: doctorRuntime.command,
1795
+ env: mergeRuntimeEnv(doctorRuntime.env),
1796
+ imageRef: image,
1797
+ onOutput: text => process.stderr.write(text)
1798
+ });
1799
+ } catch (e) {
1800
+ throw new Error(`拉取 ${image} 失败;可执行 ${MANYOYO_NAME} build --iv ${IMAGE_VERSION} 本地构建`);
1801
+ }
1802
+ return { fixed: true, message: `已拉取 ${image}` };
1803
+ },
1804
+ createConfig: () => {
1805
+ const result = syncGlobalImageVersion(IMAGE_VERSION);
1806
+ return { fixed: result.reason === 'created', message: result.reason === 'created' ? `已生成 ${result.path}` : `未生成配置: ${result.reason}` };
1807
+ },
1808
+ suggestPort: async () => {
1809
+ for (let candidate = parsedPort + 1; candidate <= Math.min(parsedPort + 50, 65535); candidate += 1) {
1810
+ if (await checkPortAvailability(candidate) === 'available') {
1811
+ return { fixed: false, message: `建议使用空闲端口 ${candidate}` };
1812
+ }
1813
+ }
1814
+ return { fixed: false, message: '附近没有空闲端口,请手动指定' };
1815
+ }
1816
+ });
1817
+ }
1537
1818
  if (options.json) {
1538
1819
  console.log(JSON.stringify(report, null, 4));
1539
1820
  } else {
1540
1821
  report.checks.forEach(check => {
1541
1822
  console.log(`[${check.status.toUpperCase()}] ${check.code}: ${check.summary}${check.action ? ` (${check.action})` : ''}`);
1823
+ if (check.fix && check.fix.attempted) {
1824
+ console.log(` ${check.fix.fixed ? '已修复' : '未修复'}: ${check.fix.message}`);
1825
+ }
1542
1826
  });
1543
1827
  }
1544
1828
  process.exit(report.ok ? 0 : 1);
@@ -1580,6 +1864,7 @@ function createRuntimeContext(modeState = {}) {
1580
1864
  contModeArgs: CONT_MODE_ARGS,
1581
1865
  containerExtraArgs: CONTAINER_EXTRA_ARGS,
1582
1866
  containerEnvs: CONTAINER_ENVS,
1867
+ mirrors: MIRRORS,
1583
1868
  firstContainerEnvs: FIRST_CONTAINER_ENVS,
1584
1869
  containerVolumes: CONTAINER_VOLUMES,
1585
1870
  containerPorts: CONTAINER_PORTS,
@@ -1815,8 +2100,9 @@ function relaunchServeDetached(runtime) {
1815
2100
  /**
1816
2101
  * 等待容器就绪(使用指数退避算法)
1817
2102
  * @param {string} containerName - 容器名称
2103
+ * @param {{throwOnFailure?: boolean}} [options] serve 路径传 true:失败抛错而不是 process.exit,调用方才能清理容器
1818
2104
  */
1819
- async function waitForContainerReady(containerName) {
2105
+ async function waitForContainerReady(containerName, options = {}) {
1820
2106
  const MAX_RETRIES = CONFIG.CONTAINER_READY_MAX_RETRIES;
1821
2107
  let retryDelay = CONFIG.CONTAINER_READY_INITIAL_DELAY;
1822
2108
 
@@ -1829,6 +2115,9 @@ async function waitForContainerReady(containerName) {
1829
2115
  }
1830
2116
 
1831
2117
  if (status === 'exited') {
2118
+ if (options.throwOnFailure) {
2119
+ throw Object.assign(new Error('容器启动后立即退出'), { containerExited: true });
2120
+ }
1832
2121
  console.log(`${RED}⚠️ 错误: 容器启动后立即退出。${NC}`);
1833
2122
  dockerExecArgs(['logs', containerName], { stdio: 'inherit' });
1834
2123
  process.exit(1);
@@ -1837,11 +2126,15 @@ async function waitForContainerReady(containerName) {
1837
2126
  await sleep(retryDelay);
1838
2127
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1839
2128
  } catch (e) {
2129
+ if (e && e.containerExited) throw e;
1840
2130
  await sleep(retryDelay);
1841
2131
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1842
2132
  }
1843
2133
  }
1844
2134
 
2135
+ if (options.throwOnFailure) {
2136
+ throw new Error('容器启动超时');
2137
+ }
1845
2138
  console.log(`${RED}⚠️ 错误: 容器启动超时。${NC}`);
1846
2139
  process.exit(1);
1847
2140
  }
@@ -1874,7 +2167,7 @@ function executeFirstCommand(runtime) {
1874
2167
  '-c',
1875
2168
  firstCommand
1876
2169
  ];
1877
- const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit' });
2170
+ const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit', env: DOCKER_ENV });
1878
2171
  if (firstExecResult.error) {
1879
2172
  throw firstExecResult.error;
1880
2173
  }
@@ -1907,6 +2200,8 @@ async function createNewContainer(runtime) {
1907
2200
  process.exit(0);
1908
2201
  }
1909
2202
 
2203
+ await ensureRunImage(runtime);
2204
+
1910
2205
  // 使用数组参数执行命令(安全方式)
1911
2206
  try {
1912
2207
  const args = buildDockerRunArgs(runtime);
@@ -1919,6 +2214,13 @@ async function createNewContainer(runtime) {
1919
2214
  // Wait for container to be ready
1920
2215
  await waitForContainerReady(runtime.containerName);
1921
2216
 
2217
+ applyAptMirror({
2218
+ dockerExecArgs,
2219
+ containerName: runtime.containerName,
2220
+ mirrors: runtime.mirrors,
2221
+ warn: message => console.warn(`${YELLOW}⚠️ ${message}${NC}`)
2222
+ });
2223
+
1922
2224
  // Run one-time bootstrap command for newly created containers only.
1923
2225
  executeFirstCommand(runtime);
1924
2226
 
@@ -1941,6 +2243,7 @@ function buildDockerRunArgs(runtime) {
1941
2243
  containerEnvs: runtime.containerEnvs,
1942
2244
  containerVolumes: runtime.containerVolumes,
1943
2245
  containerPorts: runtime.containerPorts,
2246
+ mirrors: runtime.mirrors,
1944
2247
  defaultCommand: runtime.execCommand
1945
2248
  });
1946
2249
  }
@@ -2016,9 +2319,9 @@ function executeInContainer(runtime, defaultCommand) {
2016
2319
 
2017
2320
  // Execute command in container
2018
2321
  if (runtime.execCommand) {
2019
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash', '-c', runtime.execCommand], { stdio: 'inherit' });
2322
+ spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash', '-c', runtime.execCommand], { stdio: 'inherit', env: DOCKER_ENV });
2020
2323
  } else {
2021
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash'], { stdio: 'inherit' });
2324
+ spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash'], { stdio: 'inherit', env: DOCKER_ENV });
2022
2325
  }
2023
2326
  }
2024
2327
 
@@ -2079,6 +2382,38 @@ async function handlePostExit(runtime, defaultCommand) {
2079
2382
  }
2080
2383
  }
2081
2384
 
2385
+ async function runAppLauncher({ force = null, exit = true } = {}) {
2386
+ try {
2387
+ const { headless } = detectHeadless({ force });
2388
+ await launchApp({
2389
+ headless,
2390
+ authUser: String(readManyoyoConfig().config.serverUser || '').trim() || 'admin',
2391
+ statePath: getAppStatePath(),
2392
+ isProcessRunning,
2393
+ spawnServe: port => {
2394
+ // 登录走一次性令牌;未配置密码时 serve 自己生成随机密码(标记为自动生成,向导据此让用户设置)
2395
+ const child = spawn(process.argv[0], [process.argv[1], 'serve', `127.0.0.1:${port}`], {
2396
+ detached: true,
2397
+ stdio: 'ignore'
2398
+ });
2399
+ child.unref();
2400
+ return child;
2401
+ },
2402
+ issueToken: () => issueLoginToken(getLoginTokenDir()),
2403
+ open: url => openBrowser(url),
2404
+ log: line => console.log(line),
2405
+ logPathHint: buildManyoyoLogPath('serve').path,
2406
+ commandName: MANYOYO_NAME
2407
+ });
2408
+ } catch (e) {
2409
+ console.error(`${RED}${e.message}${NC}`);
2410
+ if (!exit) return false;
2411
+ process.exit(1);
2412
+ }
2413
+ if (!exit) return true;
2414
+ process.exit(0);
2415
+ }
2416
+
2082
2417
  async function runWebServerMode(runtime) {
2083
2418
  if (!runtime.serverAuthUser || !runtime.serverAuthPass) {
2084
2419
  ensureWebServerAuthCredentials();
@@ -2087,6 +2422,48 @@ async function runWebServerMode(runtime) {
2087
2422
  runtime.serverAuthPassAuto = SERVER_AUTH_PASS_AUTO;
2088
2423
  }
2089
2424
 
2425
+ const updateChecker = createUpdateChecker({
2426
+ currentVersion: require('../package.json').version,
2427
+ installMode: appUpdate.detectInstallMode({ scriptPath: __filename }).mode,
2428
+ enabled: UPDATE_CHECK_ENABLED,
2429
+ fetchLatest: () => appUpdate.fetchLatestRelease()
2430
+ });
2431
+
2432
+ const needRuntimeHeal = !isRuntimeProven();
2433
+ if (needRuntimeHeal) {
2434
+ RUNTIME_STATE.status = 'starting';
2435
+ RUNTIME_STATE.message = '正在检查容器环境';
2436
+ }
2437
+
2438
+ // 自愈失败/超时不是终态:用户手动修好(或点向导里的“重试”)后可以再来一次
2439
+ let runtimeHealing = false;
2440
+ function startServeRuntimeHeal() {
2441
+ if (runtimeHealing) return false;
2442
+ runtimeHealing = true;
2443
+ RUNTIME_STATE.status = 'starting';
2444
+ RUNTIME_STATE.message = '正在检查容器环境';
2445
+ healContainerRuntime(state => {
2446
+ RUNTIME_STATE.status = state.status;
2447
+ RUNTIME_STATE.message = state.message;
2448
+ if (state.status === 'starting') console.log(`${YELLOW}⏳ ${state.message}...${NC}`);
2449
+ }, { async: true }).then(result => {
2450
+ if (result.status === 'ready' || result.status === 'started') {
2451
+ RUNTIME_STATE.status = 'ready';
2452
+ RUNTIME_STATE.message = '';
2453
+ } else {
2454
+ RUNTIME_STATE.status = 'failed';
2455
+ RUNTIME_STATE.message = result.message;
2456
+ console.log(`${YELLOW}⚠️ ${result.message}${NC}`);
2457
+ }
2458
+ }).catch(error => {
2459
+ RUNTIME_STATE.status = 'failed';
2460
+ RUNTIME_STATE.message = String(error && error.message || error);
2461
+ }).finally(() => {
2462
+ runtimeHealing = false;
2463
+ });
2464
+ return true;
2465
+ }
2466
+
2090
2467
  const serverHandle = await startWebServer({
2091
2468
  serverHost: runtime.serverHost,
2092
2469
  serverPort: runtime.serverPort,
@@ -2095,6 +2472,31 @@ async function runWebServerMode(runtime) {
2095
2472
  authPassAuto: runtime.serverAuthPassAuto,
2096
2473
  serveTitle: runtime.serveTitle,
2097
2474
  dockerCmd: DOCKER_CMD,
2475
+ dockerEnv: DOCKER_ENV,
2476
+ runtimeState: RUNTIME_STATE,
2477
+ retryRuntimeHeal: () => (needRuntimeHeal ? startServeRuntimeHeal() : false),
2478
+ autoPullImage: true,
2479
+ importState: () => readImportState(),
2480
+ updateInfo: () => updateChecker.getInfo(),
2481
+ loginTokenDir: getLoginTokenDir(),
2482
+ doctorCheck: async () => runDoctorChecks({
2483
+ selectRuntime: () => CONTAINER_RUNTIME,
2484
+ runCommand: runRuntimeCommandAsync,
2485
+ configExists: fs.existsSync(getManyoyoConfigPath()),
2486
+ imageName: runtime.imageName,
2487
+ imageVersion: runtime.imageVersion,
2488
+ containerMode: 'common',
2489
+ pluginConfig: {}
2490
+ }),
2491
+ doctorStartRuntime: async () => {
2492
+ const result = await ensureRuntimeReady({ runtime: CONTAINER_RUNTIME, run: runRuntimeCommandAsync });
2493
+ const fixed = result.status === 'started' || result.status === 'ready';
2494
+ if (fixed) {
2495
+ RUNTIME_STATE.status = 'ready';
2496
+ RUNTIME_STATE.message = '';
2497
+ }
2498
+ return { fixed, message: result.message };
2499
+ },
2098
2500
  hostPath: runtime.hostPath,
2099
2501
  containerPath: runtime.containerPath,
2100
2502
  imageName: runtime.imageName,
@@ -2114,6 +2516,7 @@ async function runWebServerMode(runtime) {
2114
2516
  getContainerStatus,
2115
2517
  waitForContainerReady,
2116
2518
  dockerExecArgs,
2519
+ dockerExecAsync: (args, options = {}) => runCommandAsync(DOCKER_CMD, args, { env: DOCKER_ENV, timeout: options.timeout }),
2117
2520
  showImagePullHint,
2118
2521
  removeContainer,
2119
2522
  webHistoryDir: path.join(os.homedir(), '.manyoyo', 'web-history'),
@@ -2128,6 +2531,9 @@ async function runWebServerMode(runtime) {
2128
2531
  logger: runtime.logger
2129
2532
  });
2130
2533
  writeServePidFile(runtime, serverHandle);
2534
+ if (needRuntimeHeal) {
2535
+ startServeRuntimeHeal();
2536
+ }
2131
2537
  return serverHandle;
2132
2538
  }
2133
2539
 
@@ -2189,6 +2595,7 @@ async function main() {
2189
2595
  yesMode: Boolean(modeState.yesMode),
2190
2596
  updateAgents: Boolean(modeState.updateAgents),
2191
2597
  dockerCmd: DOCKER_CMD,
2598
+ dockerEnv: DOCKER_ENV,
2192
2599
  rootDir: path.join(__dirname, '..'),
2193
2600
  loadConfig,
2194
2601
  runCmd,