@xcanwin/manyoyo 5.8.5 → 5.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,6 +128,7 @@ manyoyo serve 127.0.0.1:3000
128
128
  manyoyo serve 127.0.0.1:3000 -U admin -P 123456
129
129
  manyoyo serve 127.0.0.1:3000 -U admin -P 123456 -d
130
130
  manyoyo serve 127.0.0.1:3000 -d # 未设置密码时会打印本次随机密码
131
+ manyoyo serve 127.0.0.1:3000 --stop # 停止指定后台服务
131
132
 
132
133
  # 查看配置与命令拼装
133
134
  manyoyo config show
package/bin/manyoyo.js CHANGED
@@ -16,6 +16,12 @@ const { buildImage } = require('../lib/image-build');
16
16
  const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
17
17
  const { runPluginCommand, createPlugin } = require('../lib/plugin');
18
18
  const { buildManyoyoLogPath } = require('../lib/log-path');
19
+ const { resolveRuntimeConfig } = require('../lib/runtime-resolver');
20
+ const {
21
+ parseEnvEntry: parseEnvEntryOrThrow,
22
+ expandHomeAliasPath,
23
+ normalizeVolume
24
+ } = require('../lib/runtime-normalizers');
19
25
  const {
20
26
  sanitizeSensitiveData,
21
27
  sanitizeServeLogText,
@@ -454,23 +460,13 @@ async function askQuestion(prompt) {
454
460
  * @param {string} env - 环境变量字符串 (KEY=VALUE)
455
461
  */
456
462
  function parseEnvEntry(env) {
457
- const envText = String(env);
458
- const idx = envText.indexOf('=');
459
- if (idx <= 0) {
460
- console.error(`${RED}⚠️ 错误: env 格式应为 KEY=VALUE: ${envText}${NC}`);
461
- process.exit(1);
462
- }
463
- const key = envText.slice(0, idx);
464
- const value = envText.slice(idx + 1);
465
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
466
- console.error(`${RED}⚠️ 错误: env key 非法: ${key}${NC}`);
467
- process.exit(1);
468
- }
469
- if (/[\r\n\0]/.test(value) || /[;&|`$<>]/.test(value)) {
470
- console.error(`${RED}⚠️ 错误: env value 含非法字符: ${key}${NC}`);
463
+ try {
464
+ return parseEnvEntryOrThrow(env);
465
+ } catch (e) {
466
+ const message = e && e.message ? e.message : String(e);
467
+ console.error(`${RED}⚠️ 错误: ${message}${NC}`);
471
468
  process.exit(1);
472
469
  }
473
- return { key, value };
474
470
  }
475
471
 
476
472
  function normalizeJsonEnvMap(envConfig, sourceLabel) {
@@ -570,42 +566,6 @@ function addEnvFile(envFile) {
570
566
  return addEnvFileTo(CONTAINER_ENVS, envFile);
571
567
  }
572
568
 
573
- function expandHomeAliasPath(filePath) {
574
- const text = String(filePath || '').trim();
575
- const homeDir = process.env.HOME || os.homedir();
576
-
577
- if (text === '~') {
578
- return homeDir;
579
- }
580
- if (text.startsWith('~/')) {
581
- return path.join(homeDir, text.slice(2));
582
- }
583
- if (text === '$HOME') {
584
- return homeDir;
585
- }
586
- if (text.startsWith('$HOME/')) {
587
- return path.join(homeDir, text.slice('$HOME/'.length));
588
- }
589
-
590
- return text;
591
- }
592
-
593
- function normalizeVolume(volume) {
594
- const text = String(volume || '').trim();
595
- if (!text.startsWith('~') && !text.startsWith('$HOME')) {
596
- return text;
597
- }
598
-
599
- const separatorIndex = text.indexOf(':');
600
- if (separatorIndex === -1) {
601
- return expandHomeAliasPath(text);
602
- }
603
-
604
- const hostPath = text.slice(0, separatorIndex);
605
- const rest = text.slice(separatorIndex);
606
- return `${expandHomeAliasPath(hostPath)}${rest}`;
607
- }
608
-
609
569
  function hasEnvKey(targetEnvs, key) {
610
570
  for (let i = 0; i < targetEnvs.length; i += 2) {
611
571
  if (targetEnvs[i] !== '--env') {
@@ -1004,11 +964,7 @@ function validateShellSuffixPassThroughArgs(command) {
1004
964
  }
1005
965
  }
1006
966
 
1007
- function applyRunStyleOptions(command, options = {}) {
1008
- const includeRmOnExit = options.includeRmOnExit !== false;
1009
- const includeServePreview = options.includeServePreview === true;
1010
- const includeWebAuthOptions = options.includeWebAuthOptions === true;
1011
-
967
+ function applyContainerBaseOptions(command) {
1012
968
  command
1013
969
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
1014
970
  .option('--hp, --host-path <path>', '设置宿主机工作目录 (默认: 当前路径)')
@@ -1023,35 +979,69 @@ function applyRunStyleOptions(command, options = {}) {
1023
979
  appendArrayOption(command, '-v, --volume <volume>', '绑定挂载卷 XXX:YYY (可多次使用)');
1024
980
  appendArrayOption(command, '-p, --port <port>', '设置端口映射 XXX:YYY (可多次使用)');
1025
981
 
982
+ return command;
983
+ }
984
+
985
+ function applyExecOptions(command) {
1026
986
  command
1027
987
  .option('--sp, --shell-prefix <command>', '主命令前缀 (常用于临时环境变量)')
1028
988
  .option('-s, --shell <command>', '主命令')
1029
989
  .option('--ss, --shell-suffix <command>', '主命令后缀 (追加到 -s 之后,等价于 -- <args>)')
1030
- .option('--first-shell-prefix <command>', '首次预执行命令前缀 (仅新建容器生效; 容器已存在时忽略)')
1031
- .option('--first-shell <command>', '首次预执行命令 (仅新建容器生效; 容器已存在时忽略)')
1032
- .option('--first-shell-suffix <command>', '首次预执行命令后缀 (仅新建容器生效; 容器已存在时忽略)')
1033
990
  .option('-x, --shell-full <command...>', '完整命令 (与 --sp/-s/--ss/-- 互斥)')
1034
991
  .option('-y, --yolo <cli>', '使 AGENT 无需确认 (claude(c), gemini(gm), codex(cx), opencode(oc))');
992
+
993
+ return command;
994
+ }
995
+
996
+ function applyFirstExecOptions(command) {
997
+ command
998
+ .option('--first-shell-prefix <command>', '首次预执行命令前缀 (仅新建容器生效; 容器已存在时忽略)')
999
+ .option('--first-shell <command>', '首次预执行命令 (仅新建容器生效; 容器已存在时忽略)')
1000
+ .option('--first-shell-suffix <command>', '首次预执行命令后缀 (仅新建容器生效; 容器已存在时忽略)');
1035
1001
  appendArrayOption(command, '--first-env <env>', '首次预执行环境变量 XXX=YYY (可多次使用)');
1036
1002
  appendArrayOption(command, '--first-env-file <file>', '首次预执行环境变量文件 (仅支持绝对路径,如 /abs/path.env)');
1037
1003
 
1004
+ return command;
1005
+ }
1006
+
1007
+ function applyQuietOptions(command) {
1008
+ appendArrayOption(command, '-q, --quiet <item>', '静默输出 (可多次使用: cnew, crm, tip, cmd, full)');
1009
+ return command;
1010
+ }
1011
+
1012
+ function applyServeAuthOptions(command) {
1013
+ command
1014
+ .option('-U, --user <username>', '网页服务登录用户名 (默认 admin)')
1015
+ .option('-P, --pass <password>', '网页服务登录密码 (默认自动生成随机密码)');
1016
+ return command;
1017
+ }
1018
+
1019
+ function applyRunStyleOptions(command, options = {}) {
1020
+ const includeRmOnExit = options.includeRmOnExit !== false;
1021
+ const includeServePreview = options.includeServePreview === true;
1022
+ const includeWebAuthOptions = options.includeWebAuthOptions === true;
1023
+ const includeFirstExecOptions = options.includeFirstExecOptions !== false;
1024
+
1025
+ applyContainerBaseOptions(command);
1026
+ applyExecOptions(command);
1027
+ if (includeFirstExecOptions) {
1028
+ applyFirstExecOptions(command);
1029
+ }
1030
+
1038
1031
  if (includeRmOnExit) {
1039
1032
  command.option('--rm-on-exit', '退出后自动删除容器 (一次性模式)');
1040
1033
  }
1041
1034
 
1042
- appendArrayOption(command, '-q, --quiet <item>', '静默输出 (可多次使用: cnew, crm, tip, cmd, full)');
1035
+ applyQuietOptions(command);
1043
1036
 
1044
1037
  if (includeServePreview) {
1045
1038
  command
1046
- .option('--serve [listen]', '按 serve 模式解析配置 (仅支持 <ip:port>)')
1047
- .option('-U, --user <username>', '网页服务登录用户名 (默认 admin)')
1048
- .option('-P, --pass <password>', '网页服务登录密码 (默认自动生成随机密码)');
1039
+ .option('--serve [listen]', '按 serve 模式解析配置 (仅支持 <ip:port>)');
1040
+ applyServeAuthOptions(command);
1049
1041
  }
1050
1042
 
1051
1043
  if (includeWebAuthOptions) {
1052
- command
1053
- .option('-U, --user <username>', '网页服务登录用户名 (默认 admin)')
1054
- .option('-P, --pass <password>', '网页服务登录密码 (默认自动生成随机密码)');
1044
+ applyServeAuthOptions(command);
1055
1045
  }
1056
1046
 
1057
1047
  return command;
@@ -1214,8 +1204,13 @@ Notes:
1214
1204
  .action(() => selectAction('images', { imageList: true }));
1215
1205
 
1216
1206
  const serveCommand = program.command('serve [listen]').description('启动网页交互服务 (默认 127.0.0.1:3000)');
1217
- applyRunStyleOptions(serveCommand, { includeRmOnExit: false, includeWebAuthOptions: true });
1207
+ applyRunStyleOptions(serveCommand, {
1208
+ includeRmOnExit: false,
1209
+ includeWebAuthOptions: true,
1210
+ includeFirstExecOptions: false
1211
+ });
1218
1212
  serveCommand.option('-d, --detach', '后台启动网页服务并立即返回');
1213
+ serveCommand.option('--stop', '停止后台网页服务;必须显式传入 listen');
1219
1214
  serveCommand.action((listen, options) => {
1220
1215
  selectAction('serve', {
1221
1216
  ...options,
@@ -1259,7 +1254,10 @@ Notes:
1259
1254
  });
1260
1255
 
1261
1256
  const configRunCommand = configCommand.command('command').description('显示将执行的 docker run 命令并退出');
1262
- applyRunStyleOptions(configRunCommand, { includeRmOnExit: false });
1257
+ applyRunStyleOptions(configRunCommand, {
1258
+ includeRmOnExit: false,
1259
+ includeFirstExecOptions: false
1260
+ });
1263
1261
  enableShellSuffixPassThrough(configRunCommand);
1264
1262
  configRunCommand.action((options, command) => {
1265
1263
  validateShellSuffixPassThroughArgs(command);
@@ -1317,8 +1315,12 @@ Notes:
1317
1315
  const isShowConfigMode = selectedAction === 'config-show';
1318
1316
  const isShowCommandMode = selectedAction === 'config-command';
1319
1317
  const isServerMode = options.server !== undefined;
1318
+ const isServerStopMode = Boolean(selectedAction === 'serve' && options.stop);
1320
1319
 
1321
1320
  const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin']);
1321
+ if (isServerStopMode) {
1322
+ noDockerActions.add('serve');
1323
+ }
1322
1324
  if (!noDockerActions.has(selectedAction)) {
1323
1325
  ensureDocker();
1324
1326
  }
@@ -1362,145 +1364,96 @@ Notes:
1362
1364
  const globalFirstConfig = normalizeFirstConfig(config.first, '全局配置');
1363
1365
  const runFirstConfig = normalizeFirstConfig(runConfig.first, '运行配置');
1364
1366
 
1365
- // Merge configs: command line > run config > global config > defaults
1366
- // Override mode (scalar values): use first defined value
1367
- HOST_PATH = pickConfigValue(options.hostPath, runConfig.hostPath, config.hostPath, HOST_PATH) || HOST_PATH;
1368
- const mergedContainerName = pickConfigValue(options.contName, runConfig.containerName, config.containerName);
1369
- if (mergedContainerName) {
1370
- CONTAINER_NAME = mergedContainerName;
1371
- }
1372
- CONTAINER_NAME = resolveContainerNameTemplate(CONTAINER_NAME);
1373
- const mergedContainerPath = pickConfigValue(options.contPath, runConfig.containerPath, config.containerPath);
1374
- if (mergedContainerPath) {
1375
- CONTAINER_PATH = mergedContainerPath;
1376
- }
1377
- IMAGE_NAME = pickConfigValue(options.imageName, runConfig.imageName, config.imageName, IMAGE_NAME) || IMAGE_NAME;
1378
- const mergedImageVersion = pickConfigValue(options.imageVer, runConfig.imageVersion, config.imageVersion);
1379
- if (mergedImageVersion) {
1380
- IMAGE_VERSION = mergedImageVersion;
1381
- }
1382
- const mergedShellPrefix = pickConfigValue(options.shellPrefix, runConfig.shellPrefix, config.shellPrefix);
1383
- if (mergedShellPrefix) {
1384
- EXEC_COMMAND_PREFIX = `${mergedShellPrefix} `;
1385
- }
1386
- const mergedShell = pickConfigValue(options.shell, runConfig.shell, config.shell);
1387
- if (mergedShell) {
1388
- EXEC_COMMAND = mergedShell;
1389
- }
1390
- const mergedShellSuffix = pickConfigValue(options.shellSuffix, runConfig.shellSuffix, config.shellSuffix);
1391
- if (mergedShellSuffix) {
1392
- EXEC_COMMAND_SUFFIX = normalizeCommandSuffix(mergedShellSuffix);
1393
- }
1394
- const mergedFirstShellPrefix = pickConfigValue(options.firstShellPrefix, runFirstConfig.shellPrefix, globalFirstConfig.shellPrefix);
1395
- if (mergedFirstShellPrefix) {
1396
- FIRST_EXEC_COMMAND_PREFIX = `${mergedFirstShellPrefix} `;
1397
- }
1398
- const mergedFirstShell = pickConfigValue(options.firstShell, runFirstConfig.shell, globalFirstConfig.shell);
1399
- if (mergedFirstShell) {
1400
- FIRST_EXEC_COMMAND = mergedFirstShell;
1401
- }
1402
- const mergedFirstShellSuffix = pickConfigValue(options.firstShellSuffix, runFirstConfig.shellSuffix, globalFirstConfig.shellSuffix);
1403
- if (mergedFirstShellSuffix) {
1404
- FIRST_EXEC_COMMAND_SUFFIX = normalizeCommandSuffix(mergedFirstShellSuffix);
1405
- }
1367
+ const resolvedRuntime = resolveRuntimeConfig({
1368
+ cliOptions: options,
1369
+ globalConfig: config,
1370
+ runConfig,
1371
+ globalFirstConfig,
1372
+ runFirstConfig,
1373
+ defaults: {
1374
+ hostPath: HOST_PATH,
1375
+ containerName: CONTAINER_NAME,
1376
+ containerPath: CONTAINER_PATH,
1377
+ imageName: IMAGE_NAME,
1378
+ imageVersion: IMAGE_VERSION
1379
+ },
1380
+ envVars: process.env,
1381
+ argv: process.argv,
1382
+ isServerMode,
1383
+ isServerStopMode,
1384
+ pickConfigValue,
1385
+ resolveContainerNameTemplate,
1386
+ normalizeCommandSuffix,
1387
+ normalizeJsonEnvMap,
1388
+ normalizeCliEnvMap,
1389
+ mergeArrayConfig,
1390
+ normalizeVolume,
1391
+ parseServerListen
1392
+ });
1393
+
1394
+ HOST_PATH = resolvedRuntime.hostPath;
1395
+ CONTAINER_NAME = resolvedRuntime.containerName;
1396
+ CONTAINER_PATH = resolvedRuntime.containerPath;
1397
+ IMAGE_NAME = resolvedRuntime.imageName;
1398
+ IMAGE_VERSION = resolvedRuntime.imageVersion;
1399
+ EXEC_COMMAND_PREFIX = resolvedRuntime.exec.prefix;
1400
+ EXEC_COMMAND = resolvedRuntime.exec.shell;
1401
+ EXEC_COMMAND_SUFFIX = resolvedRuntime.exec.suffix;
1402
+ FIRST_EXEC_COMMAND_PREFIX = resolvedRuntime.first.exec.prefix;
1403
+ FIRST_EXEC_COMMAND = resolvedRuntime.first.exec.shell;
1404
+ FIRST_EXEC_COMMAND_SUFFIX = resolvedRuntime.first.exec.suffix;
1406
1405
 
1407
1406
  // Basic name validation to reduce injection risk
1408
1407
  validateName('containerName', CONTAINER_NAME, SAFE_CONTAINER_NAME_PATTERN);
1409
1408
  validateName('imageName', IMAGE_NAME, /^[A-Za-z0-9][A-Za-z0-9._/:-]*$/);
1410
1409
  validateImageVersion(IMAGE_VERSION);
1411
1410
 
1412
- // Merge mode (array values): concatenate all sources
1413
- const toArray = (val) => Array.isArray(val) ? val : (val ? [val] : []);
1414
- const envFileList = [
1415
- ...toArray(config.envFile),
1416
- ...toArray(runConfig.envFile),
1417
- ...(options.envFile || [])
1418
- ].filter(Boolean);
1411
+ const envFileList = resolvedRuntime.envFile;
1419
1412
  envFileList.forEach(ef => addEnvFile(ef));
1420
1413
 
1421
1414
  // env in JSON config uses map type, and is merged by key with CLI priority.
1422
- const envMap = {
1423
- ...normalizeJsonEnvMap(config.env, '全局配置'),
1424
- ...normalizeJsonEnvMap(runConfig.env, '运行配置'),
1425
- ...normalizeCliEnvMap(options.env)
1426
- };
1415
+ const envMap = resolvedRuntime.env;
1427
1416
  Object.entries(envMap).forEach(([key, value]) => addEnv(`${key}=${value}`));
1428
1417
 
1429
- const firstEnvFileList = [
1430
- ...toArray(globalFirstConfig.envFile),
1431
- ...toArray(runFirstConfig.envFile),
1432
- ...(options.firstEnvFile || [])
1433
- ].filter(Boolean);
1418
+ const firstEnvFileList = resolvedRuntime.first.envFile;
1434
1419
  firstEnvFileList.forEach(ef => addEnvFileTo(FIRST_CONTAINER_ENVS, ef));
1435
1420
 
1436
- const firstEnvMap = {
1437
- ...normalizeJsonEnvMap(globalFirstConfig.env, '全局配置 first'),
1438
- ...normalizeJsonEnvMap(runFirstConfig.env, '运行配置 first'),
1439
- ...normalizeCliEnvMap(options.firstEnv)
1440
- };
1421
+ const firstEnvMap = resolvedRuntime.first.env;
1441
1422
  Object.entries(firstEnvMap).forEach(([key, value]) => addEnvTo(FIRST_CONTAINER_ENVS, `${key}=${value}`));
1442
1423
 
1443
1424
  applyPlaywrightCliSessionIntegration(config, runConfig);
1444
1425
 
1445
- const volumeList = mergeArrayConfig(config.volumes, runConfig.volumes, options.volume)
1446
- .map(normalizeVolume);
1426
+ const volumeList = resolvedRuntime.volumes;
1447
1427
  volumeList.forEach(v => addVolume(v));
1448
1428
 
1449
- const portList = mergeArrayConfig(config.ports, runConfig.ports, options.port);
1429
+ const portList = resolvedRuntime.ports;
1450
1430
  portList.forEach(p => addPort(p));
1451
1431
 
1452
- const buildArgList = mergeArrayConfig(config.imageBuildArgs, runConfig.imageBuildArgs, options.imageBuildArg);
1432
+ const buildArgList = resolvedRuntime.imageBuildArgs;
1453
1433
  buildArgList.forEach(arg => addImageBuildArg(arg));
1454
1434
 
1455
1435
  // Override mode for special options
1456
- const yoloValue = pickConfigValue(options.yolo, runConfig.yolo, config.yolo);
1436
+ const yoloValue = resolvedRuntime.yolo;
1457
1437
  if (yoloValue) setYolo(yoloValue);
1458
1438
 
1459
- const contModeValue = pickConfigValue(options.contMode, runConfig.containerMode, config.containerMode);
1439
+ const contModeValue = resolvedRuntime.containerMode;
1460
1440
  if (contModeValue) setContMode(contModeValue);
1461
1441
 
1462
- const quietValue = pickConfigValue(options.quiet, runConfig.quiet, config.quiet);
1442
+ const quietValue = resolvedRuntime.quiet;
1463
1443
  if (quietValue) setQuiet(quietValue);
1464
1444
 
1465
- // Handle shell-full (variadic arguments)
1466
- if (options.shellFull) {
1467
- EXEC_COMMAND = options.shellFull.join(' ');
1468
- EXEC_COMMAND_PREFIX = "";
1469
- EXEC_COMMAND_SUFFIX = "";
1470
- }
1471
-
1472
- // Handle -- suffix arguments
1473
- if (!options.shellFull) {
1474
- const doubleDashIndex = process.argv.indexOf('--');
1475
- if (doubleDashIndex !== -1 && doubleDashIndex < process.argv.length - 1) {
1476
- EXEC_COMMAND_SUFFIX = normalizeCommandSuffix(process.argv.slice(doubleDashIndex + 1).join(' '));
1477
- }
1478
- }
1479
-
1480
1445
  if (options.rmOnExit) {
1481
1446
  RM_ON_EXIT = true;
1482
1447
  }
1483
1448
 
1484
1449
  if (isServerMode) {
1485
- const serverListen = parseServerListen(options.server);
1486
- SERVER_HOST = serverListen.host;
1487
- SERVER_PORT = serverListen.port;
1488
- }
1489
-
1490
- const serverUserValue = pickConfigValue(options.serverUser, runConfig.serverUser, config.serverUser, process.env.MANYOYO_SERVER_USER);
1491
- if (serverUserValue) {
1492
- SERVER_AUTH_USER = String(serverUserValue);
1450
+ SERVER_HOST = resolvedRuntime.serverHost;
1451
+ SERVER_PORT = resolvedRuntime.serverPort;
1493
1452
  }
1494
1453
 
1495
- const serverPassValue = pickConfigValue(options.serverPass, runConfig.serverPass, config.serverPass, process.env.MANYOYO_SERVER_PASS);
1496
- if (serverPassValue) {
1497
- SERVER_AUTH_PASS = String(serverPassValue);
1498
- SERVER_AUTH_PASS_AUTO = false;
1499
- }
1500
-
1501
- if (isServerMode) {
1502
- ensureWebServerAuthCredentials();
1503
- }
1454
+ SERVER_AUTH_USER = resolvedRuntime.serverUser || '';
1455
+ SERVER_AUTH_PASS = resolvedRuntime.serverPass || '';
1456
+ SERVER_AUTH_PASS_AUTO = resolvedRuntime.serverPassAuto === true;
1504
1457
 
1505
1458
  if (isShowConfigMode) {
1506
1459
  const finalConfig = {
@@ -1520,9 +1473,9 @@ Notes:
1520
1473
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
1521
1474
  yolo: yoloValue || "",
1522
1475
  quiet: quietValue || [],
1523
- server: isServerMode,
1524
- serverHost: isServerMode ? SERVER_HOST : null,
1525
- serverPort: isServerMode ? SERVER_PORT : null,
1476
+ server: resolvedRuntime.server,
1477
+ serverHost: resolvedRuntime.server ? SERVER_HOST : null,
1478
+ serverPort: resolvedRuntime.server ? SERVER_PORT : null,
1526
1479
  serverUser: SERVER_AUTH_USER || "",
1527
1480
  serverPass: SERVER_AUTH_PASS || "",
1528
1481
  exec: {
@@ -1560,7 +1513,9 @@ Notes:
1560
1513
  isRemoveMode,
1561
1514
  isShowCommandMode,
1562
1515
  isServerMode,
1516
+ isServerStop: isServerStopMode,
1563
1517
  isServerDetach: Boolean(selectedAction === 'serve' && options.detach),
1518
+ isServerListenSpecified: Boolean(isServerMode && options.server !== true),
1564
1519
  isPluginMode: false
1565
1520
  };
1566
1521
  }
@@ -1588,7 +1543,9 @@ function createRuntimeContext(modeState = {}) {
1588
1543
  showCommand: Boolean(modeState.isShowCommandMode),
1589
1544
  rmOnExit: RM_ON_EXIT,
1590
1545
  serverMode: Boolean(modeState.isServerMode),
1546
+ serverStop: Boolean(modeState.isServerStop),
1591
1547
  serverDetach: Boolean(modeState.isServerDetach),
1548
+ serverListenSpecified: Boolean(modeState.isServerListenSpecified),
1592
1549
  serverHost: SERVER_HOST,
1593
1550
  serverPort: SERVER_PORT,
1594
1551
  serverAuthUser: SERVER_AUTH_USER,
@@ -1657,10 +1614,138 @@ function buildDetachedServeEnv(runtime) {
1657
1614
  return env;
1658
1615
  }
1659
1616
 
1617
+ function formatServeListenHost(host) {
1618
+ const text = String(host || '').trim() || '127.0.0.1';
1619
+ if (text.includes(':') && !text.startsWith('[')) {
1620
+ return `[${text}]`;
1621
+ }
1622
+ return text;
1623
+ }
1624
+
1625
+ function buildServeListenLabel(host, port) {
1626
+ return `${formatServeListenHost(host)}:${port}`;
1627
+ }
1628
+
1629
+ function buildServePidFile(host, port, homeDir = os.homedir()) {
1630
+ const dir = path.join(homeDir, '.manyoyo', 'run', 'serve');
1631
+ const listen = buildServeListenLabel(host, port);
1632
+ const safeName = listen.replace(/[^A-Za-z0-9_.-]+/g, '_');
1633
+ return {
1634
+ dir,
1635
+ listen,
1636
+ path: path.join(dir, `${safeName}.pid`)
1637
+ };
1638
+ }
1639
+
1640
+ function removeServePidFile(filePath) {
1641
+ if (!filePath) return;
1642
+ try {
1643
+ fs.rmSync(filePath, { force: true });
1644
+ } catch (e) {
1645
+ // ignore cleanup failures
1646
+ }
1647
+ }
1648
+
1649
+ function isProcessRunning(pid) {
1650
+ if (!Number.isInteger(pid) || pid <= 0) {
1651
+ return false;
1652
+ }
1653
+ try {
1654
+ process.kill(pid, 0);
1655
+ return true;
1656
+ } catch (e) {
1657
+ return e && e.code !== 'ESRCH';
1658
+ }
1659
+ }
1660
+
1661
+ function readServePidFile(filePath) {
1662
+ try {
1663
+ const text = fs.readFileSync(filePath, 'utf-8').trim();
1664
+ if (!/^\d+$/.test(text)) {
1665
+ return 0;
1666
+ }
1667
+ return Number(text);
1668
+ } catch (e) {
1669
+ return 0;
1670
+ }
1671
+ }
1672
+
1673
+ function getServePidTarget(host, port, homeDir = os.homedir()) {
1674
+ const pidFile = buildServePidFile(host, port, homeDir);
1675
+ const pid = readServePidFile(pidFile.path);
1676
+ if (!Number.isInteger(pid) || pid <= 0 || !isProcessRunning(pid)) {
1677
+ removeServePidFile(pidFile.path);
1678
+ return null;
1679
+ }
1680
+ return {
1681
+ pid,
1682
+ listen: pidFile.listen,
1683
+ path: pidFile.path
1684
+ };
1685
+ }
1686
+
1687
+ function installServePidCleanup(pidFilePath, logger) {
1688
+ if (!pidFilePath || global.__manyoyoServePidCleanupInstalled) {
1689
+ return;
1690
+ }
1691
+ global.__manyoyoServePidCleanupInstalled = true;
1692
+ process.on('exit', () => {
1693
+ removeServePidFile(pidFilePath);
1694
+ if (logger && typeof logger.info === 'function') {
1695
+ logger.info('serve pid file removed', { pidFilePath });
1696
+ }
1697
+ });
1698
+ }
1699
+
1700
+ function writeServePidFile(runtime, serverHandle) {
1701
+ const pidFile = buildServePidFile(serverHandle.host, serverHandle.port);
1702
+ fs.mkdirSync(pidFile.dir, { recursive: true });
1703
+ fs.writeFileSync(pidFile.path, `${process.pid}\n`);
1704
+ installServePidCleanup(pidFile.path, runtime && runtime.logger);
1705
+ return pidFile.path;
1706
+ }
1707
+
1708
+ async function stopServeProcess(runtime) {
1709
+ if (!runtime || !runtime.serverListenSpecified) {
1710
+ throw new Error('serve --stop 必须显式传入 listen,例如 manyoyo serve 127.0.0.1:3000 --stop');
1711
+ }
1712
+ const target = getServePidTarget(runtime.serverHost, runtime.serverPort);
1713
+ if (!target) {
1714
+ const label = buildServeListenLabel(runtime.serverHost, runtime.serverPort);
1715
+ console.log(`${YELLOW}⚠️ 未发现运行中的 serve 实例: ${label}${NC}`);
1716
+ return;
1717
+ }
1718
+ try {
1719
+ process.kill(target.pid, 'SIGTERM');
1720
+ } catch (e) {
1721
+ if (!e || e.code !== 'ESRCH') {
1722
+ throw e;
1723
+ }
1724
+ }
1725
+ await sleep(200);
1726
+ if (isProcessRunning(target.pid)) {
1727
+ try {
1728
+ process.kill(target.pid, 'SIGKILL');
1729
+ } catch (e) {
1730
+ if (!e || e.code !== 'ESRCH') {
1731
+ throw e;
1732
+ }
1733
+ }
1734
+ }
1735
+ removeServePidFile(target.path);
1736
+ console.log(`${GREEN}✅ 已停止 serve: ${target.listen} (pid: ${target.pid})${NC}`);
1737
+ }
1738
+
1660
1739
  function relaunchServeDetached(runtime) {
1661
1740
  const serveLog = buildManyoyoLogPath('serve');
1662
1741
  fs.mkdirSync(serveLog.dir, { recursive: true });
1663
1742
 
1743
+ const existing = getServePidTarget(runtime.serverHost, runtime.serverPort);
1744
+ if (existing) {
1745
+ console.log(`${YELLOW}⚠️ serve 已在后台运行: ${existing.listen} (pid: ${existing.pid})${NC}`);
1746
+ return;
1747
+ }
1748
+
1664
1749
  const child = spawn(process.argv[0], buildDetachedServeArgv(process.argv.slice(1)), {
1665
1750
  detached: true,
1666
1751
  stdio: 'ignore',
@@ -1954,7 +2039,7 @@ async function runWebServerMode(runtime) {
1954
2039
  runtime.serverAuthPassAuto = SERVER_AUTH_PASS_AUTO;
1955
2040
  }
1956
2041
 
1957
- await startWebServer({
2042
+ const serverHandle = await startWebServer({
1958
2043
  serverHost: runtime.serverHost,
1959
2044
  serverPort: runtime.serverPort,
1960
2045
  authUser: runtime.serverAuthUser,
@@ -1993,6 +2078,8 @@ async function runWebServerMode(runtime) {
1993
2078
  },
1994
2079
  logger: runtime.logger
1995
2080
  });
2081
+ writeServePidFile(runtime, serverHandle);
2082
+ return serverHandle;
1996
2083
  }
1997
2084
 
1998
2085
  async function main() {
@@ -2015,6 +2102,10 @@ async function main() {
2015
2102
 
2016
2103
  // 2. Start web server mode
2017
2104
  if (runtime.serverMode) {
2105
+ if (runtime.serverStop) {
2106
+ await stopServeProcess(runtime);
2107
+ return;
2108
+ }
2018
2109
  if (runtime.serverDetach) {
2019
2110
  relaunchServeDetached(runtime);
2020
2111
  return;