@xcanwin/manyoyo 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,7 +122,8 @@ manyoyo run -y oc # OpenCode(或 opencode)
122
122
  manyoyo update # 更新 MANYOYO(全局 npm 安装场景)
123
123
 
124
124
  # 容器管理
125
- manyoyo ls
125
+ manyoyo ps
126
+ manyoyo images
126
127
  manyoyo run -n my-dev -x /bin/bash
127
128
  manyoyo rm my-dev
128
129
  manyoyo serve 3000
package/bin/manyoyo.js CHANGED
@@ -14,6 +14,7 @@ const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/cont
14
14
  const { initAgentConfigs } = require('../lib/init-config');
15
15
  const { buildImage } = require('../lib/image-build');
16
16
  const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
17
+ const { runPluginCommand } = require('../lib/services');
17
18
  const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
18
19
  const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
19
20
  const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
@@ -236,6 +237,7 @@ function sanitizeSensitiveData(obj) {
236
237
  * @property {Object.<string, string|number|boolean>} [env] - 环境变量映射
237
238
  * @property {string[]} [envFile] - 环境文件数组
238
239
  * @property {string[]} [volumes] - 挂载卷数组
240
+ * @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
239
241
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
240
242
  * @property {string} [yolo] - YOLO 模式
241
243
  * @property {string} [containerMode] - 容器模式
@@ -698,11 +700,49 @@ function updateManyoyo() {
698
700
 
699
701
  function getContList() {
700
702
  try {
701
- const result = execSync(`${DOCKER_CMD} ps -a --size --filter "ancestor=manyoyo" --filter "ancestor=$(${DOCKER_CMD} images -a --format '{{.Repository}}:{{.Tag}}' | grep manyoyo)" --format "table {{.Names}}\\t{{.Status}}\\t{{.Size}}\\t{{.ID}}\\t{{.Image}}\\t{{.Ports}}\\t{{.Networks}}\\t{{.Mounts}}"`,
702
- { encoding: 'utf-8' });
703
- console.log(result);
703
+ const output = dockerExecArgs([
704
+ 'ps', '-a', '--size',
705
+ '--format', '{{.Names}}\t{{.Status}}\t{{.Size}}\t{{.ID}}\t{{.Image}}\t{{.Ports}}\t{{.Networks}}\t{{.Mounts}}'
706
+ ], { stdio: 'pipe' });
707
+
708
+ const rows = output
709
+ .split('\n')
710
+ .map(line => line.trim())
711
+ .filter(Boolean)
712
+ .filter(line => {
713
+ const cols = line.split('\t');
714
+ const name = cols[0] || '';
715
+ const image = cols[4] || '';
716
+ // include manyoyo runtime containers (image match)
717
+ // and plugin containers (both legacy manyoyo-* and new my-* prefixes)
718
+ return image.includes('manyoyo') || name.startsWith('manyoyo-') || name.startsWith('my-');
719
+ });
720
+
721
+ console.log('NO.\tNAMES\tSTATUS\tSIZE\tCONTAINER ID\tIMAGE\tPORTS\tNETWORKS\tMOUNTS');
722
+ if (rows.length > 0) {
723
+ const numberedRows = rows.map((line, index) => {
724
+ return `${index + 1}.\t${line}`;
725
+ });
726
+ console.log(numberedRows.join('\n'));
727
+ }
728
+ } catch (e) {
729
+ console.log((e && e.stdout) || '');
730
+ }
731
+ }
732
+
733
+ function getImageList() {
734
+ try {
735
+ const output = dockerExecArgs(['images', '-a', '--format', '{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}']);
736
+ const lines = output
737
+ .split('\n')
738
+ .map(line => line.trim())
739
+ .filter(line => line && line.includes('manyoyo'));
740
+ console.log('REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE');
741
+ if (lines.length > 0) {
742
+ console.log(lines.join('\n'));
743
+ }
704
744
  } catch (e) {
705
- console.log(e.stdout || '');
745
+ console.log((e && e.stdout) || '');
706
746
  }
707
747
  }
708
748
 
@@ -820,16 +860,60 @@ async function setupCommander() {
820
860
  const config = loadConfig();
821
861
 
822
862
  const program = new Command();
863
+ program.enablePositionalOptions();
823
864
  let selectedAction = '';
824
865
  let selectedOptions = {};
825
866
  const selectAction = (action, options = {}) => {
826
867
  selectedAction = action;
827
868
  selectedOptions = options;
828
869
  };
870
+ const selectPluginAction = (params = {}, options = {}) => {
871
+ selectAction('plugin', {
872
+ ...options,
873
+ pluginAction: params.action || 'ls',
874
+ pluginName: params.pluginName || 'playwright',
875
+ pluginScene: params.scene || 'host-headless',
876
+ pluginHost: params.host || ''
877
+ });
878
+ };
879
+
880
+ const registerPlaywrightAliasCommands = (command) => {
881
+ command.command('ls')
882
+ .description('列出 playwright 启用场景')
883
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
884
+ .action(options => selectPluginAction({
885
+ action: 'ls',
886
+ pluginName: 'playwright',
887
+ scene: 'all'
888
+ }, options));
889
+
890
+ const actions = ['up', 'down', 'status', 'health', 'logs'];
891
+ actions.forEach(action => {
892
+ command.command(`${action} [scene]`)
893
+ .description(`${action} playwright 场景,scene 默认 host-headless`)
894
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
895
+ .action((scene, options) => selectPluginAction({
896
+ action,
897
+ pluginName: 'playwright',
898
+ scene: scene || 'host-headless'
899
+ }, options));
900
+ });
901
+
902
+ command.command('mcp-add')
903
+ .description('输出 playwright 的 MCP 接入命令')
904
+ .option('--host <host>', 'MCP URL 使用的主机名或IP (默认 host.docker.internal)')
905
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
906
+ .action(options => selectPluginAction({
907
+ action: 'mcp-add',
908
+ pluginName: 'playwright',
909
+ scene: 'all',
910
+ host: options.host || ''
911
+ }, options));
912
+ };
829
913
 
830
914
  program
831
915
  .name(MANYOYO_NAME)
832
- .version(BIN_VERSION, '-V, --version', '显示版本')
916
+ .version(BIN_VERSION, '-v, --version', '显示版本')
833
917
  .description('MANYOYO - AI Agent CLI Sandbox\nhttps://github.com/xcanwin/manyoyo')
834
918
  .addHelpText('after', `
835
919
  配置文件:
@@ -844,7 +928,7 @@ async function setupCommander() {
844
928
 
845
929
  示例:
846
930
  ${MANYOYO_NAME} update 更新 MANYOYO 到最新版本
847
- ${MANYOYO_NAME} build --iv ${IMAGE_VERSION_HELP_EXAMPLE} 构建镜像
931
+ ${MANYOYO_NAME} build --iv ${IMAGE_VERSION_HELP_EXAMPLE} --yes 构建镜像
848
932
  ${MANYOYO_NAME} init all 从本机 Agent 配置初始化 ~/.manyoyo
849
933
  ${MANYOYO_NAME} run -r claude 使用 manyoyo.json 的 runs.claude 快速启动
850
934
  ${MANYOYO_NAME} run -r codex --ss "resume --last" 使用命令后缀
@@ -853,6 +937,8 @@ async function setupCommander() {
853
937
  ${MANYOYO_NAME} run -x "echo 123" 指定命令执行
854
938
  ${MANYOYO_NAME} serve 3000 -u admin -P 123456 启动带登录认证的网页服务
855
939
  ${MANYOYO_NAME} serve 0.0.0.0:3000 监听全部网卡,便于局域网访问
940
+ ${MANYOYO_NAME} playwright up host-headless 启动 playwright 默认场景(推荐)
941
+ ${MANYOYO_NAME} plugin playwright up host-headless 通过 plugin 命名空间启动
856
942
  ${MANYOYO_NAME} run -n test -q tip -q cmd 多次使用静默选项
857
943
  `);
858
944
 
@@ -874,9 +960,13 @@ async function setupCommander() {
874
960
  .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
875
961
  .action((name, options) => selectAction('rm', { ...options, contName: name }));
876
962
 
877
- program.command('ls')
963
+ program.command('ps')
878
964
  .description('列举容器')
879
- .action(() => selectAction('ls', { contList: true }));
965
+ .action(() => selectAction('ps', { contList: true }));
966
+
967
+ program.command('images')
968
+ .description('列举镜像')
969
+ .action(() => selectAction('images', { imageList: true }));
880
970
 
881
971
  const serveCommand = program.command('serve [listen]').description('启动网页交互服务 (默认 127.0.0.1:3000)');
882
972
  applyRunStyleOptions(serveCommand, { includeRmOnExit: false, includeWebAuthOptions: true });
@@ -889,6 +979,21 @@ async function setupCommander() {
889
979
  });
890
980
  });
891
981
 
982
+ const playwrightCommand = program.command('playwright').description('管理 playwright 插件服务(推荐)');
983
+ registerPlaywrightAliasCommands(playwrightCommand);
984
+
985
+ const pluginCommand = program.command('plugin').description('管理 manyoyo 插件');
986
+ pluginCommand.command('ls')
987
+ .description('列出可用插件与启用场景')
988
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
989
+ .action(options => selectPluginAction({
990
+ action: 'ls',
991
+ pluginName: 'playwright',
992
+ scene: 'all'
993
+ }, options));
994
+ const pluginPlaywrightCommand = pluginCommand.command('playwright').description('管理 playwright 插件服务');
995
+ registerPlaywrightAliasCommands(pluginPlaywrightCommand);
996
+
892
997
  const configCommand = program.command('config').description('查看解析后的配置或命令');
893
998
  const configShowCommand = configCommand.command('show').description('显示最终生效配置并退出');
894
999
  applyRunStyleOptions(configShowCommand, { includeRmOnExit: false, includeServePreview: true });
@@ -954,13 +1059,14 @@ async function setupCommander() {
954
1059
  const yesMode = Boolean(options.yes);
955
1060
  const isBuildMode = selectedAction === 'build';
956
1061
  const isRemoveMode = selectedAction === 'rm';
957
- const isListMode = selectedAction === 'ls';
1062
+ const isPsMode = selectedAction === 'ps';
1063
+ const isImagesMode = selectedAction === 'images';
958
1064
  const isPruneMode = selectedAction === 'prune';
959
1065
  const isShowConfigMode = selectedAction === 'config-show';
960
1066
  const isShowCommandMode = selectedAction === 'config-command';
961
1067
  const isServerMode = options.server !== undefined;
962
1068
 
963
- const noDockerActions = new Set(['init', 'update', 'install', 'config-show']);
1069
+ const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin']);
964
1070
  if (!noDockerActions.has(selectedAction)) {
965
1071
  ensureDocker();
966
1072
  }
@@ -981,6 +1087,21 @@ async function setupCommander() {
981
1087
  process.exit(0);
982
1088
  }
983
1089
 
1090
+ if (selectedAction === 'plugin') {
1091
+ const runConfig = options.run ? loadRunConfig(options.run, config) : {};
1092
+ return {
1093
+ isPluginMode: true,
1094
+ pluginRequest: {
1095
+ action: options.pluginAction,
1096
+ pluginName: options.pluginName,
1097
+ scene: options.pluginScene || 'host-headless',
1098
+ host: options.pluginHost || ''
1099
+ },
1100
+ pluginGlobalConfig: config,
1101
+ pluginRunConfig: runConfig
1102
+ };
1103
+ }
1104
+
984
1105
  // Load run config if specified
985
1106
  const runConfig = options.run ? loadRunConfig(options.run, config) : {};
986
1107
 
@@ -1130,7 +1251,8 @@ async function setupCommander() {
1130
1251
  process.exit(0);
1131
1252
  }
1132
1253
 
1133
- if (isListMode) { getContList(); process.exit(0); }
1254
+ if (isPsMode) { getContList(); process.exit(0); }
1255
+ if (isImagesMode) { getImageList(); process.exit(0); }
1134
1256
  if (isPruneMode) { pruneDanglingImages(); process.exit(0); }
1135
1257
  if (selectedAction === 'install') { installManyoyo(options.install); process.exit(0); }
1136
1258
 
@@ -1139,7 +1261,8 @@ async function setupCommander() {
1139
1261
  isBuildMode,
1140
1262
  isRemoveMode,
1141
1263
  isShowCommandMode,
1142
- isServerMode
1264
+ isServerMode,
1265
+ isPluginMode: false
1143
1266
  };
1144
1267
  }
1145
1268
 
@@ -1472,6 +1595,18 @@ async function main() {
1472
1595
  try {
1473
1596
  // 1. Setup commander and parse arguments
1474
1597
  const modeState = await setupCommander();
1598
+
1599
+ if (modeState.isPluginMode) {
1600
+ const exitCode = await runPluginCommand(modeState.pluginRequest, {
1601
+ globalConfig: modeState.pluginGlobalConfig,
1602
+ runConfig: modeState.pluginRunConfig,
1603
+ projectRoot: path.join(__dirname, '..'),
1604
+ stdout: process.stdout,
1605
+ stderr: process.stderr
1606
+ });
1607
+ process.exit(exitCode);
1608
+ }
1609
+
1475
1610
  const runtime = createRuntimeContext(modeState);
1476
1611
 
1477
1612
  // 2. Start web server mode
@@ -25,6 +25,27 @@
25
25
  "imageBuildArgs": [],
26
26
  "quiet": ["tip", "cmd"],
27
27
 
28
+ // 可选插件(manyoyo playwright / manyoyo plugin playwright)
29
+ "plugins": {
30
+ "playwright": {
31
+ // mixed: 支持容器+宿主机;container: 仅容器;host: 仅宿主机
32
+ "runtime": "mixed",
33
+ // 启用场景(可按需裁剪)
34
+ "enabledScenes": ["cont-headless", "cont-headed", "host-headless", "host-headed"],
35
+ // mcp-add 默认 host(可改为 localhost / 127.0.0.1)
36
+ "mcpDefaultHost": "host.docker.internal",
37
+ // cont-headed 场景读取的密码环境变量名(默认 VNC_PASSWORD)
38
+ "vncPasswordEnvKey": "VNC_PASSWORD",
39
+ "ports": {
40
+ "contHeadless": 8931,
41
+ "contHeaded": 8932,
42
+ "hostHeadless": 8933,
43
+ "hostHeaded": 8934,
44
+ "contHeadedNoVnc": 6080
45
+ }
46
+ }
47
+ },
48
+
28
49
  // 运行配置集合:通过 -r <name> 读取 runs.<name>
29
50
  "runs": {
30
51
  "claude": {
@@ -33,6 +54,11 @@
33
54
  "shell": "claude",
34
55
  "env": {
35
56
  "ANTHROPIC_MODEL": "claude-sonnet-4-5"
57
+ },
58
+ "plugins": {
59
+ "playwright": {
60
+ "runtime": "container"
61
+ }
36
62
  }
37
63
  }
38
64
  }
@@ -136,6 +136,8 @@ RUN <<EOX
136
136
  # 配置 node.js
137
137
  npm config set registry=${NPM_REGISTRY}
138
138
 
139
+ export GIT_SSL_NO_VERIFY=$GIT_SSL_NO_VERIFY
140
+
139
141
  # 安装 LSP服务(python、typescript)
140
142
  npm install -g pyright typescript-language-server typescript
141
143
 
@@ -153,7 +155,7 @@ RUN <<EOX
153
155
  }
154
156
  }
155
157
  EOF
156
- GIT_SSL_NO_VERIFY=$GIT_SSL_NO_VERIFY claude plugin marketplace add https://github.com/anthropics/claude-plugins-official
158
+ claude plugin marketplace add https://github.com/anthropics/claude-plugins-official
157
159
  claude plugin install ralph-loop@claude-plugins-official
158
160
  claude plugin install typescript-lsp@claude-plugins-official
159
161
  claude plugin install pyright-lsp@claude-plugins-official
@@ -163,11 +165,39 @@ EOF
163
165
  case ",$TOOL," in *,full,*|*,java,*)
164
166
  claude plugin install jdtls-lsp@claude-plugins-official
165
167
  ;; esac
166
-
167
- GIT_SSL_NO_VERIFY=$GIT_SSL_NO_VERIFY claude plugin marketplace add https://github.com/anthropics/skills
168
- claude plugin install example-skills@anthropic-agent-skills
168
+ claude plugin marketplace add https://github.com/anthropics/skills
169
169
  claude plugin install document-skills@anthropic-agent-skills
170
170
 
171
+ # 安装 Codex CLI
172
+ npm install -g @openai/codex
173
+ mkdir -p ~/.codex
174
+ cat > ~/.codex/config.toml <<EOF
175
+ check_for_update_on_startup = false
176
+
177
+ [analytics]
178
+ enabled = false
179
+ EOF
180
+ mkdir -p "$HOME/.codex/skills"
181
+ git clone --depth 1 https://github.com/openai/skills.git /tmp/openai-skills
182
+ cp -a /tmp/openai-skills/skills/.system "$HOME/.codex/skills/.system"
183
+ rm -rf /tmp/openai-skills
184
+ CODEX_INSTALLER="$HOME/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py"
185
+ python "$CODEX_INSTALLER" --repo openai/skills --path \
186
+ skills/.curated/doc \
187
+ skills/.curated/spreadsheet \
188
+ skills/.curated/pdf \
189
+ skills/.curated/security-best-practices \
190
+ skills/.curated/security-threat-model
191
+ python "$CODEX_INSTALLER" --repo anthropics/skills --path \
192
+ skills/pptx \
193
+ skills/theme-factory \
194
+ skills/frontend-design \
195
+ skills/canvas-design \
196
+ skills/doc-coauthoring \
197
+ skills/internal-comms \
198
+ skills/web-artifacts-builder \
199
+ skills/webapp-testing
200
+
171
201
  # 安装 Gemini CLI
172
202
  case ",$TOOL," in *,full,*|*,gemini,*)
173
203
  npm install -g @google/gemini-cli
@@ -198,18 +228,6 @@ EOF
198
228
  EOF
199
229
  ;; esac
200
230
 
201
- # 安装 Codex CLI
202
- case ",$TOOL," in *,full,*|*,codex,*)
203
- npm install -g @openai/codex
204
- mkdir -p ~/.codex
205
- cat > ~/.codex/config.toml <<EOF
206
- check_for_update_on_startup = false
207
-
208
- [analytics]
209
- enabled = false
210
- EOF
211
- ;; esac
212
-
213
231
  # 安装 OpenCode CLI
214
232
  case ",$TOOL," in *,full,*|*,opencode,*)
215
233
  npm install -g opencode-ai
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ const { PlaywrightPlugin } = require('./playwright');
4
+
5
+ const AVAILABLE_PLUGINS = ['playwright'];
6
+
7
+ function asObject(value) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
9
+ return {};
10
+ }
11
+ return value;
12
+ }
13
+
14
+ function getPluginConfigs(pluginName, globalConfig, runConfig) {
15
+ const globalPlugins = asObject(asObject(globalConfig).plugins);
16
+ const runPlugins = asObject(asObject(runConfig).plugins);
17
+ return {
18
+ globalPluginConfig: asObject(globalPlugins[pluginName]),
19
+ runPluginConfig: asObject(runPlugins[pluginName])
20
+ };
21
+ }
22
+
23
+ function createPlugin(pluginName, options = {}) {
24
+ if (pluginName !== 'playwright') {
25
+ throw new Error(`未知插件: ${pluginName}`);
26
+ }
27
+
28
+ const cfg = getPluginConfigs(pluginName, options.globalConfig, options.runConfig);
29
+ return new PlaywrightPlugin({
30
+ projectRoot: options.projectRoot,
31
+ stdout: options.stdout,
32
+ stderr: options.stderr,
33
+ globalConfig: cfg.globalPluginConfig,
34
+ runConfig: cfg.runPluginConfig
35
+ });
36
+ }
37
+
38
+ async function runPluginCommand(request, options = {}) {
39
+ const action = String(request && request.action || '').trim();
40
+
41
+ if (action === 'ls') {
42
+ const plugin = createPlugin('playwright', options);
43
+ return await plugin.run({ action: 'ls' });
44
+ }
45
+
46
+ const pluginName = String(request && request.pluginName || '').trim();
47
+ if (!pluginName) {
48
+ throw new Error('plugin 名称不能为空');
49
+ }
50
+
51
+ const plugin = createPlugin(pluginName, options);
52
+ return await plugin.run({
53
+ action,
54
+ scene: request.scene,
55
+ host: request.host
56
+ });
57
+ }
58
+
59
+ module.exports = {
60
+ AVAILABLE_PLUGINS,
61
+ createPlugin,
62
+ runPluginCommand
63
+ };
@@ -0,0 +1,19 @@
1
+ services:
2
+ playwright:
3
+ build:
4
+ context: .
5
+ dockerfile: headed.Dockerfile
6
+ args:
7
+ PLAYWRIGHT_MCP_BASE_IMAGE: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_DOCKER_TAG:-latest}
8
+ image: ${PLAYWRIGHT_MCP_IMAGE:-localhost/xcanwin/manyoyo-playwright-headed}
9
+ container_name: ${PLAYWRIGHT_MCP_CONTAINER_NAME:-my-playwright-cont-headed}
10
+ init: true
11
+ stdin_open: true
12
+ ports:
13
+ - "127.0.0.1:${PLAYWRIGHT_MCP_PORT:-8932}:${PLAYWRIGHT_MCP_PORT:-8932}"
14
+ - "127.0.0.1:${PLAYWRIGHT_MCP_NOVNC_PORT:-6080}:6080"
15
+ environment:
16
+ VNC_PASSWORD: "${VNC_PASSWORD:?VNC_PASSWORD is required}"
17
+ volumes:
18
+ - ${PLAYWRIGHT_MCP_CONFIG_PATH:?PLAYWRIGHT_MCP_CONFIG_PATH is required}:/app/config/playwright.json:ro
19
+ command: ["node", "cli.js", "--config", "/app/config/playwright.json"]
@@ -0,0 +1,11 @@
1
+ services:
2
+ playwright:
3
+ image: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_DOCKER_TAG:-latest}
4
+ container_name: ${PLAYWRIGHT_MCP_CONTAINER_NAME:-my-playwright-cont-headless}
5
+ init: true
6
+ stdin_open: true
7
+ ports:
8
+ - "127.0.0.1:${PLAYWRIGHT_MCP_PORT:-8931}:${PLAYWRIGHT_MCP_PORT:-8931}"
9
+ volumes:
10
+ - ${PLAYWRIGHT_MCP_CONFIG_PATH:?PLAYWRIGHT_MCP_CONFIG_PATH is required}:/app/config/playwright.json:ro
11
+ command: ["--config", "/app/config/playwright.json"]
@@ -0,0 +1,15 @@
1
+ ARG PLAYWRIGHT_MCP_BASE_IMAGE=mcr.microsoft.com/playwright/mcp:latest
2
+ FROM ${PLAYWRIGHT_MCP_BASE_IMAGE}
3
+
4
+ USER root
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends \
7
+ ca-certificates xvfb x11vnc novnc websockify fluxbox && \
8
+ update-ca-certificates && \
9
+ apt-get clean && \
10
+ rm -rf /tmp/* /var/tmp/* /var/log/apt /var/log/*.log /var/lib/apt/lists/*
11
+
12
+ COPY init-headed.sh /usr/local/bin/init-headed.sh
13
+ RUN chmod +x /usr/local/bin/init-headed.sh
14
+
15
+ ENTRYPOINT ["/usr/local/bin/init-headed.sh"]
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export DISPLAY=:99
5
+
6
+ Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
7
+ fluxbox >/tmp/fluxbox.log 2>&1 &
8
+
9
+ if [[ -z "${VNC_PASSWORD:-}" ]]; then
10
+ echo "VNC_PASSWORD is required."
11
+ exit 1
12
+ fi
13
+
14
+ x11vnc -display :99 -forever -shared -rfbport 5900 -localhost -passwd "$VNC_PASSWORD" >/tmp/x11vnc.log 2>&1 &
15
+ websockify --web=/usr/share/novnc/ 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
16
+
17
+ exec "$@"
@@ -0,0 +1,792 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const net = require('net');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn, spawnSync } = require('child_process');
8
+
9
+ const SCENE_ORDER = ['cont-headless', 'cont-headed', 'host-headless', 'host-headed'];
10
+
11
+ const SCENE_DEFS = {
12
+ 'cont-headless': {
13
+ type: 'container',
14
+ configFile: 'container-headless.json',
15
+ composeFile: 'compose-headless.yaml',
16
+ projectName: 'my-playwright-cont-headless',
17
+ containerName: 'my-playwright-cont-headless',
18
+ portKey: 'contHeadless',
19
+ headless: true,
20
+ listenHost: '0.0.0.0'
21
+ },
22
+ 'cont-headed': {
23
+ type: 'container',
24
+ configFile: 'container-headed.json',
25
+ composeFile: 'compose-headed.yaml',
26
+ projectName: 'my-playwright-cont-headed',
27
+ containerName: 'my-playwright-cont-headed',
28
+ portKey: 'contHeaded',
29
+ headless: false,
30
+ listenHost: '0.0.0.0'
31
+ },
32
+ 'host-headless': {
33
+ type: 'host',
34
+ configFile: 'host-headless.json',
35
+ portKey: 'hostHeadless',
36
+ headless: true,
37
+ listenHost: '127.0.0.1'
38
+ },
39
+ 'host-headed': {
40
+ type: 'host',
41
+ configFile: 'host-headed.json',
42
+ portKey: 'hostHeaded',
43
+ headless: false,
44
+ listenHost: '127.0.0.1'
45
+ }
46
+ };
47
+
48
+ const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
49
+ const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
50
+
51
+ function sleep(ms) {
52
+ return new Promise(resolve => setTimeout(resolve, ms));
53
+ }
54
+
55
+ function tailText(filePath, lineCount) {
56
+ if (!fs.existsSync(filePath)) {
57
+ return '';
58
+ }
59
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
60
+ return lines.slice(-lineCount).join('\n');
61
+ }
62
+
63
+ function asObject(value) {
64
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
65
+ return {};
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function asStringArray(value, fallback) {
71
+ if (!Array.isArray(value)) {
72
+ return fallback;
73
+ }
74
+ return value
75
+ .map(item => String(item || '').trim())
76
+ .filter(Boolean);
77
+ }
78
+
79
+ class PlaywrightPlugin {
80
+ constructor(options = {}) {
81
+ this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
82
+ this.stdout = options.stdout || process.stdout;
83
+ this.stderr = options.stderr || process.stderr;
84
+ this.globalConfig = asObject(options.globalConfig);
85
+ this.runConfig = asObject(options.runConfig);
86
+ this.config = this.resolveConfig();
87
+ }
88
+
89
+ resolveConfig() {
90
+ const homeDir = os.homedir();
91
+ const defaultConfig = {
92
+ runtime: 'mixed',
93
+ enabledScenes: [...SCENE_ORDER],
94
+ hostListen: '127.0.0.1',
95
+ mcpDefaultHost: 'host.docker.internal',
96
+ dockerTag: process.env.PLAYWRIGHT_MCP_DOCKER_TAG || 'latest',
97
+ npmVersion: process.env.PLAYWRIGHT_MCP_NPM_VERSION || 'latest',
98
+ containerRuntime: 'podman',
99
+ vncPasswordEnvKey: 'VNC_PASSWORD',
100
+ headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
101
+ configDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'config'),
102
+ runDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'run'),
103
+ composeDir: path.join(__dirname, 'playwright-assets'),
104
+ ports: {
105
+ contHeadless: 8931,
106
+ contHeaded: 8932,
107
+ hostHeadless: 8933,
108
+ hostHeaded: 8934,
109
+ contHeadedNoVnc: 6080
110
+ }
111
+ };
112
+
113
+ const merged = {
114
+ ...defaultConfig,
115
+ ...this.globalConfig,
116
+ ...this.runConfig,
117
+ ports: {
118
+ ...defaultConfig.ports,
119
+ ...asObject(this.globalConfig.ports),
120
+ ...asObject(this.runConfig.ports)
121
+ }
122
+ };
123
+
124
+ merged.runtime = String(merged.runtime || defaultConfig.runtime).trim().toLowerCase();
125
+ if (!VALID_RUNTIME.has(merged.runtime)) {
126
+ throw new Error(`playwright.runtime 无效: ${merged.runtime}`);
127
+ }
128
+
129
+ merged.enabledScenes = asStringArray(
130
+ this.runConfig.enabledScenes,
131
+ asStringArray(this.globalConfig.enabledScenes, [...defaultConfig.enabledScenes])
132
+ );
133
+
134
+ if (merged.enabledScenes.length === 0) {
135
+ throw new Error('playwright.enabledScenes 不能为空');
136
+ }
137
+
138
+ const invalidScene = merged.enabledScenes.find(scene => !SCENE_DEFS[scene]);
139
+ if (invalidScene) {
140
+ throw new Error(`playwright.enabledScenes 包含未知场景: ${invalidScene}`);
141
+ }
142
+
143
+ return merged;
144
+ }
145
+
146
+ writeStdout(line = '') {
147
+ this.stdout.write(`${line}\n`);
148
+ }
149
+
150
+ writeStderr(line = '') {
151
+ this.stderr.write(`${line}\n`);
152
+ }
153
+
154
+ runCmd(args, { env = null, captureOutput = false, check = true } = {}) {
155
+ const result = spawnSync(args[0], args.slice(1), {
156
+ encoding: 'utf8',
157
+ env: env || process.env,
158
+ stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
159
+ });
160
+
161
+ const completed = {
162
+ returncode: result.status === null ? 1 : result.status,
163
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
164
+ stderr: typeof result.stderr === 'string' ? result.stderr : ''
165
+ };
166
+
167
+ if (result.error) {
168
+ if (check) {
169
+ throw result.error;
170
+ }
171
+ completed.returncode = 1;
172
+ return completed;
173
+ }
174
+
175
+ if (check && completed.returncode !== 0) {
176
+ const error = new Error(`command failed with exit code ${completed.returncode}`);
177
+ error.returncode = completed.returncode;
178
+ error.stdout = completed.stdout;
179
+ error.stderr = completed.stderr;
180
+ throw error;
181
+ }
182
+
183
+ return completed;
184
+ }
185
+
186
+ ensureCommandAvailable(command) {
187
+ const check = this.runCmd([command, '--version'], { captureOutput: true, check: false });
188
+ return check.returncode === 0;
189
+ }
190
+
191
+ scenePort(sceneName) {
192
+ const def = SCENE_DEFS[sceneName];
193
+ return Number(this.config.ports[def.portKey]);
194
+ }
195
+
196
+ sceneConfigPath(sceneName) {
197
+ const def = SCENE_DEFS[sceneName];
198
+ return path.join(this.config.configDir, def.configFile);
199
+ }
200
+
201
+ scenePidFile(sceneName) {
202
+ return path.join(this.config.runDir, `${sceneName}.pid`);
203
+ }
204
+
205
+ sceneLogFile(sceneName) {
206
+ return path.join(this.config.runDir, `${sceneName}.log`);
207
+ }
208
+
209
+ resolveTargets(sceneName = 'all') {
210
+ const requested = String(sceneName || 'all').trim();
211
+ const enabledSet = new Set(this.config.enabledScenes);
212
+ const runtime = this.config.runtime;
213
+
214
+ const isAllowedByRuntime = (scene) => {
215
+ const type = SCENE_DEFS[scene].type;
216
+ if (runtime === 'mixed') {
217
+ return true;
218
+ }
219
+ return runtime === type;
220
+ };
221
+
222
+ if (requested !== 'all') {
223
+ if (!SCENE_DEFS[requested]) {
224
+ throw new Error(`未知场景: ${requested}`);
225
+ }
226
+ if (!enabledSet.has(requested)) {
227
+ throw new Error(`场景未启用: ${requested}`);
228
+ }
229
+ if (!isAllowedByRuntime(requested)) {
230
+ throw new Error(`当前 runtime=${runtime},不允许场景: ${requested}`);
231
+ }
232
+ return [requested];
233
+ }
234
+
235
+ return SCENE_ORDER
236
+ .filter(scene => enabledSet.has(scene))
237
+ .filter(scene => isAllowedByRuntime(scene));
238
+ }
239
+
240
+ buildSceneConfig(sceneName) {
241
+ const def = SCENE_DEFS[sceneName];
242
+ const port = this.scenePort(sceneName);
243
+
244
+ return {
245
+ server: {
246
+ host: def.listenHost,
247
+ port,
248
+ allowedHosts: [
249
+ `localhost:${port}`,
250
+ `127.0.0.1:${port}`,
251
+ `host.docker.internal:${port}`,
252
+ `host.containers.internal:${port}`
253
+ ]
254
+ },
255
+ browser: {
256
+ chromiumSandbox: true,
257
+ browserName: 'chromium',
258
+ launchOptions: {
259
+ channel: 'chromium',
260
+ headless: def.headless
261
+ },
262
+ contextOptions: {
263
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36'
264
+ }
265
+ }
266
+ };
267
+ }
268
+
269
+ ensureSceneConfig(sceneName) {
270
+ fs.mkdirSync(this.config.configDir, { recursive: true });
271
+ const payload = this.buildSceneConfig(sceneName);
272
+ const filePath = this.sceneConfigPath(sceneName);
273
+ fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
274
+ return filePath;
275
+ }
276
+
277
+ async portReady(port) {
278
+ return await new Promise((resolve) => {
279
+ let settled = false;
280
+ const socket = net.createConnection({ host: '127.0.0.1', port });
281
+
282
+ const finish = (value) => {
283
+ if (settled) {
284
+ return;
285
+ }
286
+ settled = true;
287
+ socket.destroy();
288
+ resolve(value);
289
+ };
290
+
291
+ socket.setTimeout(300);
292
+ socket.once('connect', () => finish(true));
293
+ socket.once('timeout', () => finish(false));
294
+ socket.once('error', () => finish(false));
295
+ });
296
+ }
297
+
298
+ async waitForPort(port) {
299
+ for (let i = 0; i < 30; i += 1) {
300
+ // eslint-disable-next-line no-await-in-loop
301
+ if (await this.portReady(port)) {
302
+ return true;
303
+ }
304
+ // eslint-disable-next-line no-await-in-loop
305
+ await sleep(500);
306
+ }
307
+ return false;
308
+ }
309
+
310
+ containerEnv(sceneName, cfgPath, options = {}) {
311
+ const def = SCENE_DEFS[sceneName];
312
+ const requireVncPassword = options.requireVncPassword === true;
313
+ const env = {
314
+ ...process.env,
315
+ PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
316
+ PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
317
+ PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
318
+ PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
319
+ PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
320
+ PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.contHeadedNoVnc)
321
+ };
322
+
323
+ if (sceneName === 'cont-headed') {
324
+ const envKey = this.config.vncPasswordEnvKey;
325
+ const password = process.env[envKey];
326
+ if (!password && requireVncPassword) {
327
+ throw new Error(`${envKey} is required for cont-headed`);
328
+ }
329
+ // podman-compose resolves ${VNC_PASSWORD:?..} even for `down`.
330
+ // Keep `up` strict, but use a non-empty placeholder for non-up actions.
331
+ env.VNC_PASSWORD = password || '__MANYOYO_PLACEHOLDER__';
332
+ }
333
+
334
+ return env;
335
+ }
336
+
337
+ containerComposePath(sceneName) {
338
+ const def = SCENE_DEFS[sceneName];
339
+ return path.join(this.config.composeDir, def.composeFile);
340
+ }
341
+
342
+ async startContainer(sceneName) {
343
+ const runtime = this.config.containerRuntime;
344
+ if (!this.ensureCommandAvailable(runtime)) {
345
+ this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
346
+ return 1;
347
+ }
348
+
349
+ const cfgPath = this.ensureSceneConfig(sceneName);
350
+ const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
351
+ const def = SCENE_DEFS[sceneName];
352
+
353
+ try {
354
+ this.runCmd([
355
+ runtime,
356
+ 'compose',
357
+ '-p',
358
+ def.projectName,
359
+ '-f',
360
+ this.containerComposePath(sceneName),
361
+ 'up',
362
+ '-d'
363
+ ], { env, check: true });
364
+ } catch (error) {
365
+ return error.returncode || 1;
366
+ }
367
+
368
+ const port = this.scenePort(sceneName);
369
+ if (await this.waitForPort(port)) {
370
+ this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
371
+ return 0;
372
+ }
373
+
374
+ this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
375
+ return 1;
376
+ }
377
+
378
+ stopContainer(sceneName) {
379
+ const runtime = this.config.containerRuntime;
380
+ if (!this.ensureCommandAvailable(runtime)) {
381
+ this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
382
+ return 1;
383
+ }
384
+
385
+ const cfgPath = this.sceneConfigPath(sceneName);
386
+ const env = this.containerEnv(sceneName, cfgPath);
387
+ const def = SCENE_DEFS[sceneName];
388
+
389
+ try {
390
+ this.runCmd([
391
+ runtime,
392
+ 'compose',
393
+ '-p',
394
+ def.projectName,
395
+ '-f',
396
+ this.containerComposePath(sceneName),
397
+ 'down'
398
+ ], { env, check: true });
399
+ } catch (error) {
400
+ return error.returncode || 1;
401
+ }
402
+
403
+ this.writeStdout(`[down] ${sceneName}`);
404
+ return 0;
405
+ }
406
+
407
+ statusContainer(sceneName) {
408
+ const runtime = this.config.containerRuntime;
409
+ if (!this.ensureCommandAvailable(runtime)) {
410
+ this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
411
+ return 1;
412
+ }
413
+
414
+ const def = SCENE_DEFS[sceneName];
415
+ const cp = this.runCmd([
416
+ runtime,
417
+ 'ps',
418
+ '--filter',
419
+ `name=${def.containerName}`,
420
+ '--format',
421
+ '{{.Names}}'
422
+ ], { captureOutput: true, check: false });
423
+
424
+ const names = new Set(
425
+ cp.stdout
426
+ .split(/\r?\n/)
427
+ .map(line => line.trim())
428
+ .filter(Boolean)
429
+ );
430
+
431
+ if (names.has(def.containerName)) {
432
+ this.writeStdout(`[status] ${sceneName} running`);
433
+ } else {
434
+ this.writeStdout(`[status] ${sceneName} stopped`);
435
+ }
436
+ return 0;
437
+ }
438
+
439
+ logsContainer(sceneName) {
440
+ const runtime = this.config.containerRuntime;
441
+ if (!this.ensureCommandAvailable(runtime)) {
442
+ this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
443
+ return 1;
444
+ }
445
+
446
+ const def = SCENE_DEFS[sceneName];
447
+ const cp = this.runCmd([
448
+ runtime,
449
+ 'logs',
450
+ '--tail',
451
+ '80',
452
+ def.containerName
453
+ ], { captureOutput: true, check: false });
454
+
455
+ const output = cp.stdout || cp.stderr;
456
+ if (output.trim()) {
457
+ this.writeStdout(output.trimEnd());
458
+ } else {
459
+ this.writeStdout(`[logs] ${sceneName} no logs`);
460
+ }
461
+
462
+ return cp.returncode === 0 ? 0 : 1;
463
+ }
464
+
465
+ spawnHostProcess(cfgPath, logFd) {
466
+ return spawn('npx', [`@playwright/mcp@${this.config.npmVersion}`, '--config', String(cfgPath)], {
467
+ detached: true,
468
+ stdio: ['ignore', logFd, logFd]
469
+ });
470
+ }
471
+
472
+ stopHostStarter(pid) {
473
+ if (!Number.isInteger(pid) || pid <= 0) {
474
+ return;
475
+ }
476
+ try {
477
+ process.kill(-pid, 'SIGTERM');
478
+ return;
479
+ } catch {
480
+ // no-op
481
+ }
482
+ try {
483
+ process.kill(pid, 'SIGTERM');
484
+ } catch {
485
+ // no-op
486
+ }
487
+ }
488
+
489
+ hostScenePids(sceneName) {
490
+ const cfgPath = this.sceneConfigPath(sceneName);
491
+ const pattern = `playwright-mcp.*--config ${cfgPath}`;
492
+ const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
493
+
494
+ if (cp.returncode !== 0 || !cp.stdout.trim()) {
495
+ return [];
496
+ }
497
+
498
+ const pids = [];
499
+ for (const line of cp.stdout.split(/\r?\n/)) {
500
+ const text = line.trim();
501
+ if (/^\d+$/.test(text)) {
502
+ pids.push(Number(text));
503
+ }
504
+ }
505
+ return pids;
506
+ }
507
+
508
+ async waitForHostPids(sceneName, fallbackPid) {
509
+ for (let i = 0; i < 5; i += 1) {
510
+ const pids = this.hostScenePids(sceneName);
511
+ if (pids.length > 0) {
512
+ return pids;
513
+ }
514
+ // eslint-disable-next-line no-await-in-loop
515
+ await sleep(100);
516
+ }
517
+ if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
518
+ return [fallbackPid];
519
+ }
520
+ return [];
521
+ }
522
+
523
+ async startHost(sceneName) {
524
+ if (!this.ensureCommandAvailable('npx')) {
525
+ this.writeStderr(`[up] ${sceneName} failed: npx command not found.`);
526
+ return 1;
527
+ }
528
+
529
+ fs.mkdirSync(this.config.runDir, { recursive: true });
530
+ const cfgPath = this.ensureSceneConfig(sceneName);
531
+ const pidFile = this.scenePidFile(sceneName);
532
+ const logFile = this.sceneLogFile(sceneName);
533
+ const port = this.scenePort(sceneName);
534
+
535
+ let managedPids = this.hostScenePids(sceneName);
536
+ if (managedPids.length > 0 && (await this.portReady(port))) {
537
+ this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
538
+ return 0;
539
+ }
540
+
541
+ if (await this.portReady(port)) {
542
+ this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
543
+ this.writeStderr('Stop the conflicting process first, then retry.');
544
+ return 1;
545
+ }
546
+
547
+ fs.rmSync(pidFile, { force: true });
548
+ const logFd = fs.openSync(logFile, 'a');
549
+ const starter = this.spawnHostProcess(cfgPath, logFd);
550
+ fs.closeSync(logFd);
551
+ if (typeof starter.unref === 'function') {
552
+ starter.unref();
553
+ }
554
+
555
+ if (await this.waitForPort(port)) {
556
+ managedPids = await this.waitForHostPids(sceneName, starter.pid);
557
+ if (managedPids.length > 0) {
558
+ fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
559
+ this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
560
+ return 0;
561
+ }
562
+ }
563
+
564
+ this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
565
+ const tail = tailText(logFile, 30);
566
+ if (tail) {
567
+ this.writeStderr(tail);
568
+ }
569
+
570
+ if (starter.exitCode === null && !starter.killed) {
571
+ this.stopHostStarter(starter.pid);
572
+ }
573
+
574
+ return 1;
575
+ }
576
+
577
+ async stopHost(sceneName) {
578
+ const pidFile = this.scenePidFile(sceneName);
579
+ const port = this.scenePort(sceneName);
580
+ const managedPids = this.hostScenePids(sceneName);
581
+
582
+ for (const pid of managedPids) {
583
+ try {
584
+ process.kill(pid, 'SIGTERM');
585
+ } catch {
586
+ // no-op
587
+ }
588
+ }
589
+
590
+ if (managedPids.length > 0) {
591
+ await sleep(300);
592
+ }
593
+
594
+ if (fs.existsSync(pidFile)) {
595
+ const text = fs.readFileSync(pidFile, 'utf8').trim();
596
+ if (/^\d+$/.test(text)) {
597
+ try {
598
+ process.kill(Number(text), 'SIGTERM');
599
+ } catch {
600
+ // no-op
601
+ }
602
+ }
603
+ fs.rmSync(pidFile, { force: true });
604
+ }
605
+
606
+ if (await this.portReady(port)) {
607
+ this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
608
+ return 1;
609
+ }
610
+
611
+ this.writeStdout(`[down] ${sceneName}`);
612
+ return 0;
613
+ }
614
+
615
+ async statusHost(sceneName) {
616
+ const pidFile = this.scenePidFile(sceneName);
617
+ const port = this.scenePort(sceneName);
618
+ const managedPids = this.hostScenePids(sceneName);
619
+
620
+ if (managedPids.length > 0 && (await this.portReady(port))) {
621
+ this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
622
+ const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
623
+ if (!pidfileValid) {
624
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
625
+ fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
626
+ }
627
+ return 0;
628
+ }
629
+
630
+ if (managedPids.length > 0 && !(await this.portReady(port))) {
631
+ this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
632
+ return 0;
633
+ }
634
+
635
+ fs.rmSync(pidFile, { force: true });
636
+ if (await this.portReady(port)) {
637
+ this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
638
+ } else {
639
+ this.writeStdout(`[status] ${sceneName} stopped`);
640
+ }
641
+ return 0;
642
+ }
643
+
644
+ async healthScene(sceneName) {
645
+ const port = this.scenePort(sceneName);
646
+ if (await this.portReady(port)) {
647
+ this.writeStdout(`[health] ${sceneName} ok (127.0.0.1:${port})`);
648
+ return 0;
649
+ }
650
+ this.writeStdout(`[health] ${sceneName} fail (127.0.0.1:${port})`);
651
+ return 1;
652
+ }
653
+
654
+ logsHost(sceneName) {
655
+ const logFile = this.sceneLogFile(sceneName);
656
+ if (!fs.existsSync(logFile)) {
657
+ this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
658
+ return 0;
659
+ }
660
+
661
+ const tail = tailText(logFile, 80);
662
+ if (tail) {
663
+ this.writeStdout(tail);
664
+ }
665
+ return 0;
666
+ }
667
+
668
+ detectCurrentIPv4() {
669
+ const interfaces = os.networkInterfaces();
670
+ for (const values of Object.values(interfaces)) {
671
+ if (!Array.isArray(values)) {
672
+ continue;
673
+ }
674
+ for (const item of values) {
675
+ if (!item || item.internal) {
676
+ continue;
677
+ }
678
+ if (item.family === 'IPv4') {
679
+ return item.address;
680
+ }
681
+ }
682
+ }
683
+ return '';
684
+ }
685
+
686
+ resolveMcpAddHost(hostArg) {
687
+ if (!hostArg) {
688
+ return this.config.mcpDefaultHost;
689
+ }
690
+ const value = String(hostArg).trim();
691
+ if (!value) {
692
+ return '';
693
+ }
694
+ if (value === 'current-ip') {
695
+ return this.detectCurrentIPv4();
696
+ }
697
+ return value;
698
+ }
699
+
700
+ printMcpAdd(hostArg) {
701
+ const host = this.resolveMcpAddHost(hostArg);
702
+ if (!host) {
703
+ this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
704
+ return 1;
705
+ }
706
+
707
+ const scenes = this.resolveTargets('all');
708
+ for (const sceneName of scenes) {
709
+ const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
710
+ this.writeStdout(`claude mcp add --transport http playwright-${sceneName} ${url}`);
711
+ }
712
+ this.writeStdout('');
713
+ for (const sceneName of scenes) {
714
+ const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
715
+ this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
716
+ }
717
+
718
+ return 0;
719
+ }
720
+
721
+ printSummary() {
722
+ const scenes = this.resolveTargets('all');
723
+ this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
724
+ return 0;
725
+ }
726
+
727
+ async runOnScene(action, sceneName) {
728
+ const def = SCENE_DEFS[sceneName];
729
+ if (action === 'up') {
730
+ return def.type === 'container'
731
+ ? await this.startContainer(sceneName)
732
+ : await this.startHost(sceneName);
733
+ }
734
+ if (action === 'down') {
735
+ return def.type === 'container'
736
+ ? this.stopContainer(sceneName)
737
+ : await this.stopHost(sceneName);
738
+ }
739
+ if (action === 'status') {
740
+ return def.type === 'container'
741
+ ? this.statusContainer(sceneName)
742
+ : await this.statusHost(sceneName);
743
+ }
744
+ if (action === 'health') {
745
+ return await this.healthScene(sceneName);
746
+ }
747
+ if (action === 'logs') {
748
+ return def.type === 'container'
749
+ ? this.logsContainer(sceneName)
750
+ : this.logsHost(sceneName);
751
+ }
752
+ this.writeStderr(`unknown action: ${action}`);
753
+ return 1;
754
+ }
755
+
756
+ async run({ action, scene = 'host-headless', host = '' }) {
757
+ if (action === 'ls') {
758
+ return this.printSummary();
759
+ }
760
+
761
+ if (action === 'mcp-add') {
762
+ return this.printMcpAdd(host);
763
+ }
764
+
765
+ if (!VALID_ACTIONS.has(action)) {
766
+ throw new Error(`未知 plugin 动作: ${action}`);
767
+ }
768
+
769
+ const targets = this.resolveTargets(scene);
770
+ if (targets.length === 0) {
771
+ this.writeStderr('没有可执行场景,请检查 runtime 与 enabledScenes 配置');
772
+ return 1;
773
+ }
774
+
775
+ let rc = 0;
776
+ for (const sceneName of targets) {
777
+ // eslint-disable-next-line no-await-in-loop
778
+ const code = await this.runOnScene(action, sceneName);
779
+ if (code !== 0) {
780
+ rc = 1;
781
+ }
782
+ }
783
+
784
+ return rc;
785
+ }
786
+ }
787
+
788
+ module.exports = {
789
+ SCENE_ORDER,
790
+ SCENE_DEFS,
791
+ PlaywrightPlugin
792
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "5.0.0",
4
- "imageVersion": "1.8.0-common",
3
+ "version": "5.1.0",
4
+ "imageVersion": "1.8.1-common",
5
5
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
6
6
  "keywords": [
7
7
  "manyoyo",