@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 +2 -1
- package/bin/manyoyo.js +147 -12
- package/config.example.json +26 -0
- package/docker/manyoyo.Dockerfile +34 -16
- package/lib/services/index.js +63 -0
- package/lib/services/playwright-assets/compose-headed.yaml +19 -0
- package/lib/services/playwright-assets/compose-headless.yaml +11 -0
- package/lib/services/playwright-assets/headed.Dockerfile +15 -0
- package/lib/services/playwright-assets/init-headed.sh +17 -0
- package/lib/services/playwright.js +792 -0
- package/package.json +2 -2
package/README.md
CHANGED
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
|
|
702
|
-
|
|
703
|
-
|
|
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, '-
|
|
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('
|
|
963
|
+
program.command('ps')
|
|
878
964
|
.description('列举容器')
|
|
879
|
-
.action(() => selectAction('
|
|
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
|
|
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 (
|
|
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
|
package/config.example.json
CHANGED
|
@@ -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
|
-
|
|
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