@xcanwin/manyoyo 7.1.6 → 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,
@@ -47,6 +64,10 @@ function formatDate() {
47
64
  }
48
65
 
49
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
+
50
71
  const rawArgv1 = process.argv[1] || '';
51
72
  const baseName = path.basename(rawArgv1).replace(/\.(cjs|mjs|js)$/i, '');
52
73
 
@@ -68,7 +89,7 @@ const CONFIG = {
68
89
  let CONTAINER_NAME = `my-${formatDate()}`;
69
90
  let HOST_PATH = process.cwd();
70
91
  let CONTAINER_PATH = HOST_PATH;
71
- let IMAGE_NAME = "localhost/xcanwin/manyoyo";
92
+ let IMAGE_NAME = "ghcr.io/xcanwin/manyoyo";
72
93
  let IMAGE_VERSION = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
73
94
  let EXEC_COMMAND = "";
74
95
  let EXEC_COMMAND_PREFIX = "";
@@ -105,6 +126,16 @@ const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)
105
126
 
106
127
  // Docker command (will be set by ensure_docker)
107
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']);
108
139
  const SUPPORTED_INIT_AGENTS = ['claude', 'codex', 'gemini', 'opencode'];
109
140
 
110
141
  function sleep(ms) {
@@ -312,6 +343,9 @@ function installServeProcessDiagnostics(logger) {
312
343
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
313
344
  * @property {string} [yolo] - YOLO 模式
314
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;仅全局配置生效,请求不附带任何本机信息)
315
349
  * @property {number} [cacheTTL] - 缓存过期天数
316
350
  * @property {string} [nodeMirror] - Node.js 镜像源
317
351
  */
@@ -672,17 +706,39 @@ function setContMode(mode) {
672
706
  }
673
707
  }
674
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
+
675
735
  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)) {
736
+ const info = describeError(getCommandFailureText(err), { imageRef: `${IMAGE_NAME}:${IMAGE_VERSION}` });
737
+ if (!info || !['IMAGE_PULL_FAILED', 'IMAGE_NOT_FOUND'].includes(info.code)) {
681
738
  return;
682
739
  }
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}`);
740
+ console.log(`${YELLOW}💡 提示: ${info.reason}${NC}`);
741
+ console.log(`${YELLOW} ${info.action}${NC}`);
686
742
  }
687
743
 
688
744
  function getCommandFailureText(err) {
@@ -692,25 +748,6 @@ function getCommandFailureText(err) {
692
748
  return `${message}\n${stderr}\n${stdout}`;
693
749
  }
694
750
 
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
751
  function runCmd(cmd, args, options = {}) {
715
752
  const result = spawnSync(cmd, args, { encoding: 'utf-8', ...options });
716
753
  if (result.error) {
@@ -739,11 +776,12 @@ function checkPortAvailability(port) {
739
776
 
740
777
  function dockerExecArgs(args, options = {}) {
741
778
  try {
742
- return runCmd(DOCKER_CMD, args, options);
779
+ return runCmd(DOCKER_CMD, args, { env: DOCKER_ENV, ...options });
743
780
  } 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}`;
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}`;
747
785
  }
748
786
  throw e;
749
787
  }
@@ -764,19 +802,51 @@ function removeContainer(name) {
764
802
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${GREEN}✅ 已彻底删除。${NC}`);
765
803
  }
766
804
 
767
- function ensureDocker() {
768
- const commands = ['docker', 'podman'];
769
- for (const cmd of commands) {
770
- try {
771
- runCmd(cmd, ['--version'], { stdio: 'pipe' });
772
- DOCKER_CMD = cmd;
773
- return true;
774
- } catch (e) {
775
- // Try next command
776
- }
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);
777
834
  }
778
- console.error("docker/podman not found");
779
- 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;
780
850
  }
781
851
 
782
852
  function installManyoyo(name) {
@@ -797,7 +867,130 @@ function installManyoyo(name) {
797
867
  process.exit(0);
798
868
  }
799
869
 
800
- 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
+
801
994
  let isLocalFileInstall = false;
802
995
  let currentVersion = 'unknown';
803
996
 
@@ -902,27 +1095,11 @@ function getImageList() {
902
1095
  }
903
1096
 
904
1097
  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}`);
1098
+ pruneDanglingImagesSafely({
1099
+ dockerExecArgs,
1100
+ log: line => console.log(line),
1101
+ colors: { YELLOW, GREEN, NC }
1102
+ });
926
1103
  }
927
1104
 
928
1105
  function maybeHandleDockerPluginMetadata(argv) {
@@ -1263,6 +1440,11 @@ Notes:
1263
1440
  selectAction('config-command', options);
1264
1441
  });
1265
1442
 
1443
+ program.command('uninstall')
1444
+ .description('卸载离线包安装的 MANYOYO:停止服务,删除程序与 PATH 配置,用户数据逐项询问(--yes 不会删用户数据)')
1445
+ .option('--yes', '确认卸载程序本身,不再询问(不会删除配置、历史、日志、工作目录和外部运行时里的容器镜像)')
1446
+ .action(options => selectAction('uninstall', options));
1447
+
1266
1448
  const initCommand = program.command('init [agents]').description('初始化 Agent 配置到 ~/.manyoyo');
1267
1449
  initCommand
1268
1450
  .option('--yes', '所有提示自动确认 (用于CI/脚本)')
@@ -1273,11 +1455,24 @@ Notes:
1273
1455
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1274
1456
  .option('--port <port>', '检查指定监听端口')
1275
1457
  .option('--json', '以 JSON 输出稳定诊断结果')
1458
+ .option('--fix', '自动修复可修复项(启动容器环境、拉取镜像、生成默认配置,端口占用时给出建议端口)')
1276
1459
  .action(options => selectAction('doctor', { ...options, doctor: true }));
1277
1460
 
1278
1461
  program.command('update')
1279
- .description('更新 MANYOYO(若检测为本地 file 安装则跳过)')
1280
- .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', {}));
1281
1476
 
1282
1477
  program.command('install <name>')
1283
1478
  .description(`安装 ${MANYOYO_NAME} 命令 (docker-cli-plugin)`)
@@ -1295,9 +1490,10 @@ Notes:
1295
1490
  // Docker CLI plugin mode - remove first arg if running as plugin
1296
1491
  normalizeDockerPluginArgv(process.argv);
1297
1492
 
1298
- // No args: show help instead of starting container
1299
- if (process.argv.length <= 2) {
1300
- 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 });
1301
1497
  }
1302
1498
 
1303
1499
  // Pre-handle -x/--shell-full: treat all following args as a single command
@@ -1330,16 +1526,58 @@ Notes:
1330
1526
  throw new Error('serve --stop 与 --restart 不能同时使用');
1331
1527
  }
1332
1528
 
1333
- 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']);
1334
1532
  if (isServerStopMode) {
1335
1533
  noDockerActions.add('serve');
1336
1534
  }
1337
1535
  if (!noDockerActions.has(selectedAction)) {
1338
- ensureDocker();
1536
+ await ensureDocker(config.containerRuntime, { deferHeal: selectedAction === 'serve' });
1339
1537
  }
1340
1538
 
1341
1539
  if (options.update) {
1342
- 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
+ });
1343
1581
  process.exit(0);
1344
1582
  }
1345
1583
 
@@ -1483,6 +1721,8 @@ Notes:
1483
1721
  worktreeRepoRoot: resolvedRuntime.worktreeRepoRoot,
1484
1722
  worktreeMainRepoRoot: resolvedRuntime.worktreeMainRepoRoot,
1485
1723
  containerMode: contModeValue || "",
1724
+ containerRuntime: config.containerRuntime || "auto",
1725
+ mirrors: MIRRORS,
1486
1726
  shellPrefix: EXEC_COMMAND_PREFIX.trim(),
1487
1727
  shell: EXEC_COMMAND || "",
1488
1728
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
@@ -1525,8 +1765,13 @@ Notes:
1525
1765
  const portStatus = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
1526
1766
  ? await checkPortAvailability(parsedPort)
1527
1767
  : undefined;
1528
- const report = runDoctorChecks({
1529
- 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,
1530
1775
  configExists: fs.existsSync(getManyoyoConfigPath()),
1531
1776
  imageName: IMAGE_NAME,
1532
1777
  imageVersion: IMAGE_VERSION,
@@ -1535,11 +1780,49 @@ Notes:
1535
1780
  pluginConfig: config.plugins,
1536
1781
  portStatus
1537
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
+ }
1538
1818
  if (options.json) {
1539
1819
  console.log(JSON.stringify(report, null, 4));
1540
1820
  } else {
1541
1821
  report.checks.forEach(check => {
1542
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
+ }
1543
1826
  });
1544
1827
  }
1545
1828
  process.exit(report.ok ? 0 : 1);
@@ -1581,6 +1864,7 @@ function createRuntimeContext(modeState = {}) {
1581
1864
  contModeArgs: CONT_MODE_ARGS,
1582
1865
  containerExtraArgs: CONTAINER_EXTRA_ARGS,
1583
1866
  containerEnvs: CONTAINER_ENVS,
1867
+ mirrors: MIRRORS,
1584
1868
  firstContainerEnvs: FIRST_CONTAINER_ENVS,
1585
1869
  containerVolumes: CONTAINER_VOLUMES,
1586
1870
  containerPorts: CONTAINER_PORTS,
@@ -1816,8 +2100,9 @@ function relaunchServeDetached(runtime) {
1816
2100
  /**
1817
2101
  * 等待容器就绪(使用指数退避算法)
1818
2102
  * @param {string} containerName - 容器名称
2103
+ * @param {{throwOnFailure?: boolean}} [options] serve 路径传 true:失败抛错而不是 process.exit,调用方才能清理容器
1819
2104
  */
1820
- async function waitForContainerReady(containerName) {
2105
+ async function waitForContainerReady(containerName, options = {}) {
1821
2106
  const MAX_RETRIES = CONFIG.CONTAINER_READY_MAX_RETRIES;
1822
2107
  let retryDelay = CONFIG.CONTAINER_READY_INITIAL_DELAY;
1823
2108
 
@@ -1830,6 +2115,9 @@ async function waitForContainerReady(containerName) {
1830
2115
  }
1831
2116
 
1832
2117
  if (status === 'exited') {
2118
+ if (options.throwOnFailure) {
2119
+ throw Object.assign(new Error('容器启动后立即退出'), { containerExited: true });
2120
+ }
1833
2121
  console.log(`${RED}⚠️ 错误: 容器启动后立即退出。${NC}`);
1834
2122
  dockerExecArgs(['logs', containerName], { stdio: 'inherit' });
1835
2123
  process.exit(1);
@@ -1838,11 +2126,15 @@ async function waitForContainerReady(containerName) {
1838
2126
  await sleep(retryDelay);
1839
2127
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1840
2128
  } catch (e) {
2129
+ if (e && e.containerExited) throw e;
1841
2130
  await sleep(retryDelay);
1842
2131
  retryDelay = Math.min(retryDelay * 2, CONFIG.CONTAINER_READY_MAX_DELAY);
1843
2132
  }
1844
2133
  }
1845
2134
 
2135
+ if (options.throwOnFailure) {
2136
+ throw new Error('容器启动超时');
2137
+ }
1846
2138
  console.log(`${RED}⚠️ 错误: 容器启动超时。${NC}`);
1847
2139
  process.exit(1);
1848
2140
  }
@@ -1875,7 +2167,7 @@ function executeFirstCommand(runtime) {
1875
2167
  '-c',
1876
2168
  firstCommand
1877
2169
  ];
1878
- const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit' });
2170
+ const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit', env: DOCKER_ENV });
1879
2171
  if (firstExecResult.error) {
1880
2172
  throw firstExecResult.error;
1881
2173
  }
@@ -1908,6 +2200,8 @@ async function createNewContainer(runtime) {
1908
2200
  process.exit(0);
1909
2201
  }
1910
2202
 
2203
+ await ensureRunImage(runtime);
2204
+
1911
2205
  // 使用数组参数执行命令(安全方式)
1912
2206
  try {
1913
2207
  const args = buildDockerRunArgs(runtime);
@@ -1920,6 +2214,13 @@ async function createNewContainer(runtime) {
1920
2214
  // Wait for container to be ready
1921
2215
  await waitForContainerReady(runtime.containerName);
1922
2216
 
2217
+ applyAptMirror({
2218
+ dockerExecArgs,
2219
+ containerName: runtime.containerName,
2220
+ mirrors: runtime.mirrors,
2221
+ warn: message => console.warn(`${YELLOW}⚠️ ${message}${NC}`)
2222
+ });
2223
+
1923
2224
  // Run one-time bootstrap command for newly created containers only.
1924
2225
  executeFirstCommand(runtime);
1925
2226
 
@@ -1942,6 +2243,7 @@ function buildDockerRunArgs(runtime) {
1942
2243
  containerEnvs: runtime.containerEnvs,
1943
2244
  containerVolumes: runtime.containerVolumes,
1944
2245
  containerPorts: runtime.containerPorts,
2246
+ mirrors: runtime.mirrors,
1945
2247
  defaultCommand: runtime.execCommand
1946
2248
  });
1947
2249
  }
@@ -2017,9 +2319,9 @@ function executeInContainer(runtime, defaultCommand) {
2017
2319
 
2018
2320
  // Execute command in container
2019
2321
  if (runtime.execCommand) {
2020
- 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 });
2021
2323
  } else {
2022
- 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 });
2023
2325
  }
2024
2326
  }
2025
2327
 
@@ -2080,6 +2382,38 @@ async function handlePostExit(runtime, defaultCommand) {
2080
2382
  }
2081
2383
  }
2082
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
+
2083
2417
  async function runWebServerMode(runtime) {
2084
2418
  if (!runtime.serverAuthUser || !runtime.serverAuthPass) {
2085
2419
  ensureWebServerAuthCredentials();
@@ -2088,6 +2422,48 @@ async function runWebServerMode(runtime) {
2088
2422
  runtime.serverAuthPassAuto = SERVER_AUTH_PASS_AUTO;
2089
2423
  }
2090
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
+
2091
2467
  const serverHandle = await startWebServer({
2092
2468
  serverHost: runtime.serverHost,
2093
2469
  serverPort: runtime.serverPort,
@@ -2096,6 +2472,31 @@ async function runWebServerMode(runtime) {
2096
2472
  authPassAuto: runtime.serverAuthPassAuto,
2097
2473
  serveTitle: runtime.serveTitle,
2098
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
+ },
2099
2500
  hostPath: runtime.hostPath,
2100
2501
  containerPath: runtime.containerPath,
2101
2502
  imageName: runtime.imageName,
@@ -2115,6 +2516,7 @@ async function runWebServerMode(runtime) {
2115
2516
  getContainerStatus,
2116
2517
  waitForContainerReady,
2117
2518
  dockerExecArgs,
2519
+ dockerExecAsync: (args, options = {}) => runCommandAsync(DOCKER_CMD, args, { env: DOCKER_ENV, timeout: options.timeout }),
2118
2520
  showImagePullHint,
2119
2521
  removeContainer,
2120
2522
  webHistoryDir: path.join(os.homedir(), '.manyoyo', 'web-history'),
@@ -2129,6 +2531,9 @@ async function runWebServerMode(runtime) {
2129
2531
  logger: runtime.logger
2130
2532
  });
2131
2533
  writeServePidFile(runtime, serverHandle);
2534
+ if (needRuntimeHeal) {
2535
+ startServeRuntimeHeal();
2536
+ }
2132
2537
  return serverHandle;
2133
2538
  }
2134
2539
 
@@ -2190,6 +2595,7 @@ async function main() {
2190
2595
  yesMode: Boolean(modeState.yesMode),
2191
2596
  updateAgents: Boolean(modeState.updateAgents),
2192
2597
  dockerCmd: DOCKER_CMD,
2598
+ dockerEnv: DOCKER_ENV,
2193
2599
  rootDir: path.join(__dirname, '..'),
2194
2600
  loadConfig,
2195
2601
  runCmd,