mooncat-browser 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser-op/backend/browserd.cjs +4 -1
- package/browser-op/extension/server.cjs +3 -2
- package/browser-op/extension/service.cjs +7 -2
- package/browser-op/index.cjs +14 -17
- package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-JoFB8k4y.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/manifest.json +1 -1
- package/browser-op/webplater/dist/chrome-mv3/offscreen.html +1 -1
- package/browser-op/webplater/entrypoints/offscreen/main.ts +4 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +363 -85
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +63 -42
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -73
- package/dist/config.js.map +1 -1
- package/dist/instance.d.ts +57 -0
- package/dist/instance.d.ts.map +1 -0
- package/dist/instance.js +128 -0
- package/dist/instance.js.map +1 -0
- package/dist/prepare-extension.d.ts +37 -0
- package/dist/prepare-extension.d.ts.map +1 -0
- package/dist/prepare-extension.js +144 -0
- package/dist/prepare-extension.js.map +1 -0
- package/dist/server.d.ts +40 -23
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +51 -35
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/browser/SKILL.md +37 -14
- package/skills/browser/references/collect.md +4 -2
- package/skills/browser/references/high-risk.md +27 -14
- package/skills/browser/references/probing.md +6 -4
- package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-CFXYw9Mo.js +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,39 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
// -*- coding: utf-8 -*-
|
|
2
3
|
// cli.ts — mooncat-browser 命令行入口。
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// mooncat-browser health [--port 17322]
|
|
7
|
-
// mooncat-browser stop [--port 17322]
|
|
8
|
-
// mooncat-browser open --url <url> [--port 17322]
|
|
9
|
-
// mooncat-browser install-extension
|
|
10
|
-
// mooncat-browser skills [--skill <name>] [--target <dir>] [--list]
|
|
5
|
+
// mooncat-browser 是独立包:不写全局 appData,不假设 mooncat space 目录。
|
|
6
|
+
// 所有运行数据由项目配置(config/browser.json 或 mooncat-browser.config.json)显式声明。
|
|
11
7
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// 命令:
|
|
9
|
+
// mooncat-browser init [name] 初始化项目配置 + 数据目录
|
|
10
|
+
// mooncat-browser start <name> 启动实例(wrapper + browserd + Chrome)
|
|
11
|
+
// mooncat-browser start <name> --temp 临时实例(系统 temp,退出清理)
|
|
12
|
+
// mooncat-browser stop <name> graceful 停止实例
|
|
13
|
+
// mooncat-browser status <name> 查实例运行状态
|
|
14
|
+
// mooncat-browser health <name> 探测 browserd 是否就绪
|
|
15
|
+
// mooncat-browser open <name> --url <url> 打开/复用浏览器并导航
|
|
16
|
+
// mooncat-browser list 列出配置里的实例 + 运行状态
|
|
17
|
+
// mooncat-browser install-extension 打印 WebPlater 扩展路径
|
|
18
|
+
// mooncat-browser skills [--skill <name>] 安装内置 skills
|
|
19
|
+
//
|
|
20
|
+
// <name> 是 config 里 instances 的 key(默认实例叫 default)。
|
|
21
|
+
// --rpc-port / --health-port / --profile 等作为高级覆盖(覆盖配置文件值)。
|
|
14
22
|
import { Command } from "commander";
|
|
15
23
|
import { createRequire } from "node:module";
|
|
16
|
-
import { resolveConfig } from "./config.js";
|
|
17
24
|
import { BrowserClient } from "./client.js";
|
|
18
25
|
import { startService, readServiceState, serviceStatePath, browserdScriptPath } from "./server.js";
|
|
26
|
+
import { defaultInstanceEntry, findProjectConfig, InstanceNotFoundError, listInstanceNames, loadInstance, NotInitializedError, tempInstance, } from "./instance.js";
|
|
27
|
+
import { prepareExtension, verifyInstanceExtension } from "./prepare-extension.js";
|
|
19
28
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
29
|
import { join } from "node:path";
|
|
22
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
30
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
23
31
|
const require = createRequire(import.meta.url);
|
|
24
32
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
25
|
-
/** webplater
|
|
33
|
+
/** webplater 扩展已构建产物目录(install-extension 指向这里)。 */
|
|
26
34
|
function extensionDistDir() {
|
|
27
35
|
return process.env.MOONCAT_BROWSER_EXTENSION || join(__dirname, "..", "browser-op", "webplater", "dist", "chrome-mv3");
|
|
28
36
|
}
|
|
29
37
|
function printHeader(title) {
|
|
30
38
|
console.log(`\n[mooncat-browser] ${title}`);
|
|
31
39
|
}
|
|
32
|
-
/**
|
|
33
|
-
function
|
|
34
|
-
|
|
40
|
+
/** 把 commander 解析的 opts 转成 CliOverrides(只取高级覆盖字段)。 */
|
|
41
|
+
function optsToOverrides(opts) {
|
|
42
|
+
const num = (v) => (typeof v === "number" ? v : undefined);
|
|
43
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
44
|
+
return {
|
|
45
|
+
rpcPort: num(opts.rpcPort) ?? num(opts.port),
|
|
46
|
+
port: num(opts.port) ?? num(opts.rpcPort),
|
|
47
|
+
healthPort: num(opts.healthPort),
|
|
48
|
+
extensionPort: num(opts.extensionPort),
|
|
49
|
+
profileDir: str(opts.profile),
|
|
50
|
+
stateDir: str(opts.stateDir),
|
|
51
|
+
logsDir: str(opts.logsDir),
|
|
52
|
+
extensionDir: str(opts.extensionDir),
|
|
53
|
+
chromePath: str(opts.chrome),
|
|
54
|
+
routeMode: str(opts.routeMode),
|
|
55
|
+
};
|
|
35
56
|
}
|
|
36
|
-
/** 轮询 rpcPort
|
|
57
|
+
/** 轮询 rpcPort,直到连不上(browserd 已退)或超时。返回 true 表示已退出。 */
|
|
37
58
|
async function waitForExit(rpcPort, timeoutMs) {
|
|
38
59
|
const deadline = Date.now() + timeoutMs;
|
|
39
60
|
while (Date.now() < deadline) {
|
|
@@ -41,7 +62,7 @@ async function waitForExit(rpcPort, timeoutMs) {
|
|
|
41
62
|
await fetch(`http://127.0.0.1:${rpcPort}/health`, {
|
|
42
63
|
signal: AbortSignal.timeout(1000),
|
|
43
64
|
});
|
|
44
|
-
//
|
|
65
|
+
// 还活着,继续等
|
|
45
66
|
}
|
|
46
67
|
catch {
|
|
47
68
|
return true; // 连不上 = 已退
|
|
@@ -50,72 +71,243 @@ async function waitForExit(rpcPort, timeoutMs) {
|
|
|
50
71
|
}
|
|
51
72
|
return false;
|
|
52
73
|
}
|
|
74
|
+
/** 友好处理 NotInitializedError / InstanceNotFoundError,其它原样抛。 */
|
|
75
|
+
function handleConfigError(e) {
|
|
76
|
+
if (e instanceof NotInitializedError || e instanceof InstanceNotFoundError) {
|
|
77
|
+
console.error(`\n[mooncat-browser] ${e.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 判断实例是否真在跑且 state 与响应进程对得上。
|
|
84
|
+
* 只 curl 到 health 端口不够——可能是上一个僵尸 wrapper 占着端口。
|
|
85
|
+
* 必须校验响应里的 wrapperPid == state.pid,才是这个 state 对应的进程在响应。
|
|
86
|
+
* 返回: "running" | "stale"(state 有但进程不对/没响应)| "stopped"(无 state)。
|
|
87
|
+
*/
|
|
88
|
+
async function realInstanceStatus(cfg) {
|
|
89
|
+
const state = readServiceState(cfg);
|
|
90
|
+
if (!state)
|
|
91
|
+
return "stopped";
|
|
92
|
+
try {
|
|
93
|
+
const resp = await fetch(`http://127.0.0.1:${state.healthPort}/health`, {
|
|
94
|
+
signal: AbortSignal.timeout(1000),
|
|
95
|
+
});
|
|
96
|
+
if (!resp.ok)
|
|
97
|
+
return "stale";
|
|
98
|
+
const body = (await resp.json());
|
|
99
|
+
// wrapperPid 对得上 state.pid = 这个 state 的进程还活着
|
|
100
|
+
if (typeof body.wrapperPid === "number" && body.wrapperPid === state.pid)
|
|
101
|
+
return "running";
|
|
102
|
+
// 端口响应了但 pid 不是 state 记录的 = 僵尸/被占用
|
|
103
|
+
return "stale";
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return "stale";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
53
109
|
async function run() {
|
|
54
110
|
const program = new Command();
|
|
55
111
|
program
|
|
56
112
|
.name("mooncat-browser")
|
|
57
|
-
.description("
|
|
113
|
+
.description("浏览器自动化工具包:可独立启动的本地服务 + JS/TS client (实例化、项目级配置)")
|
|
58
114
|
.version(getVersion());
|
|
115
|
+
// ─── init ───────────────────────────────────────────────────────────────
|
|
116
|
+
program
|
|
117
|
+
.command("init [name]")
|
|
118
|
+
.description("初始化项目配置 + 数据目录(默认实例名 default)")
|
|
119
|
+
.option("--dir <path>", "数据根目录(默认 .browser)")
|
|
120
|
+
.action((name, opts) => {
|
|
121
|
+
const instName = name || "default";
|
|
122
|
+
const dataRoot = opts.dir || ".browser";
|
|
123
|
+
const cwd = process.cwd();
|
|
124
|
+
// 选 config/browser.json 作为默认配置文件(config/ 目录更直观)
|
|
125
|
+
const cfgDir = join(cwd, "config");
|
|
126
|
+
const cfgPath = join(cfgDir, "browser.json");
|
|
127
|
+
if (existsSync(cfgPath)) {
|
|
128
|
+
console.error(`\n[mooncat-browser] config already exists: ${cfgPath}`);
|
|
129
|
+
console.error(` to add an instance, edit it manually or remove it first`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
133
|
+
const entry = {
|
|
134
|
+
...defaultInstanceEntry(instName),
|
|
135
|
+
profileDir: `${dataRoot}/${instName}/profile`,
|
|
136
|
+
stateDir: `${dataRoot}/${instName}/state`,
|
|
137
|
+
logsDir: `${dataRoot}/${instName}/logs`,
|
|
138
|
+
extensionDir: `${dataRoot}/${instName}/extension`,
|
|
139
|
+
};
|
|
140
|
+
const file = { instances: { [instName]: entry } };
|
|
141
|
+
writeFileSync(cfgPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
142
|
+
// 预创建数据目录(加 .gitkeep,方便进版本库)。extension 目录不预创建——
|
|
143
|
+
// 它由 prepare-extension 命令显式生成(含占位符替换),不该手动建空壳。
|
|
144
|
+
for (const sub of ["profile", "state", "logs"]) {
|
|
145
|
+
const d = join(cwd, dataRoot, instName, sub);
|
|
146
|
+
mkdirSync(d, { recursive: true });
|
|
147
|
+
writeFileSync(join(d, ".gitkeep"), "", "utf8");
|
|
148
|
+
}
|
|
149
|
+
printHeader(`initialized`);
|
|
150
|
+
console.log(` config : ${cfgPath}`);
|
|
151
|
+
console.log(` instance : ${instName}`);
|
|
152
|
+
console.log(` data : ${dataRoot}/${instName}/{profile,state,logs}`);
|
|
153
|
+
console.log(`\n next:`);
|
|
154
|
+
console.log(` mooncat-browser start ${instName} # CDP 路直跑`);
|
|
155
|
+
console.log(` mooncat-browser prepare-extension ${instName} # extension 路需先生成扩展目录`);
|
|
156
|
+
});
|
|
157
|
+
// ─── prepare-extension ──────────────────────────────────────────────────
|
|
158
|
+
program
|
|
159
|
+
.command("prepare-extension <name>")
|
|
160
|
+
.description("为实例生成 unpacked 扩展目录(替换 WS 端口占位符;然后手动 load unpacked 到 profileDir)")
|
|
161
|
+
.action((name, opts) => {
|
|
162
|
+
let cfg;
|
|
163
|
+
try {
|
|
164
|
+
const handle = findProjectConfig(process.cwd());
|
|
165
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
handleConfigError(e);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
printHeader(`prepare extension for instance "${name}"`);
|
|
172
|
+
try {
|
|
173
|
+
const { replacedFiles, metadata, templateDir } = prepareExtension(cfg);
|
|
174
|
+
console.log(` template : ${templateDir}`);
|
|
175
|
+
console.log(` target : ${cfg.extensionDir}`);
|
|
176
|
+
console.log(` extensionPort : ${cfg.extensionPort}`);
|
|
177
|
+
console.log(` hostWsUrl : ${metadata.hostWsUrl}`);
|
|
178
|
+
console.log(` replaced files: ${replacedFiles}`);
|
|
179
|
+
console.log(` build version : ${metadata.sourceBuildVersion}`);
|
|
180
|
+
console.log(`\n next: 手动 load unpacked 到本实例的 Chrome profile`);
|
|
181
|
+
console.log(` 1. mooncat-browser start ${name} --route-mode extension`);
|
|
182
|
+
console.log(` 2. Chrome -> chrome://extensions -> 开发者模式 -> 加载已解压的扩展程序`);
|
|
183
|
+
console.log(` 3. 选择目录: ${cfg.extensionDir}`);
|
|
184
|
+
console.log(` 4. 重启浏览器(close + open)走 extension 路`);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
console.error(`\n [ERROR] prepare failed: ${e.message}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
59
191
|
// ─── start ──────────────────────────────────────────────────────────────
|
|
60
192
|
program
|
|
61
|
-
.command("start")
|
|
62
|
-
.description("
|
|
63
|
-
.option("-p, --port <port>", "browserd RPC
|
|
193
|
+
.command("start <name>")
|
|
194
|
+
.description("启动实例(wrapper + browserd 常驻 + Chrome)")
|
|
195
|
+
.option("-p, --port <port>", "高级覆盖:browserd RPC 端口", (v) => Number(v))
|
|
64
196
|
.option("--rpc-port <port>", "同 --port", (v) => Number(v))
|
|
65
|
-
.option("--health-port <port>", "wrapper health
|
|
66
|
-
.option("--
|
|
67
|
-
.option("--
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
197
|
+
.option("--health-port <port>", "高级覆盖:wrapper health 端口", (v) => Number(v))
|
|
198
|
+
.option("--extension-port <port>", "高级覆盖:extension WS 端口(第一版不消费)", (v) => Number(v))
|
|
199
|
+
.option("--profile <path>", "高级覆盖:Chrome user-data-dir")
|
|
200
|
+
.option("--state-dir <path>", "高级覆盖:wrapper/browserd 状态目录")
|
|
201
|
+
.option("--logs-dir <path>", "高级覆盖:日志目录")
|
|
202
|
+
.option("--chrome <path>", "高级覆盖:Chrome 可执行文件路径")
|
|
203
|
+
.option("--route-mode <mode>", "高级覆盖:路由模式(auto|cdp|extension)")
|
|
204
|
+
.option("--temp", "临时实例(系统 temp,退出清理,不读项目配置)")
|
|
205
|
+
.action(async (name, opts) => {
|
|
206
|
+
let cfg;
|
|
207
|
+
let cleanup = null;
|
|
208
|
+
try {
|
|
209
|
+
if (opts.temp) {
|
|
210
|
+
const t = tempInstance(name, optsToOverrides(opts));
|
|
211
|
+
cfg = t.cfg;
|
|
212
|
+
cleanup = t.cleanup;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const handle = findProjectConfig(process.cwd());
|
|
216
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
handleConfigError(e);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
printHeader(`starting instance "${cfg.name}"`);
|
|
224
|
+
console.log(` rpc port : ${cfg.rpcPort} (clients connect here)`);
|
|
225
|
+
console.log(` health port : ${cfg.healthPort}`);
|
|
226
|
+
console.log(` extension port: ${cfg.extensionPort} (WS port for WebPlater extension)`);
|
|
227
|
+
console.log(` profile : ${cfg.profileDir}`);
|
|
228
|
+
console.log(` state : ${cfg.stateDir}`);
|
|
229
|
+
console.log(` logs : ${cfg.logsDir}`);
|
|
230
|
+
console.log(` extension dir : ${cfg.extensionDir}`);
|
|
231
|
+
console.log(` chrome : ${cfg.chromePath || "(auto-detect)"}`);
|
|
232
|
+
console.log(` browserd : ${browserdScriptPath()}`);
|
|
233
|
+
if (opts.temp)
|
|
234
|
+
console.log(` mode : TEMP (cleaned on exit)`);
|
|
235
|
+
// route=extension 前置检查:实例 extensionDir 必须先 prepare。
|
|
236
|
+
// route=cdp/auto 不需要(temp 也不需要)。
|
|
237
|
+
const wantExtension = cfg.routeMode === "extension";
|
|
238
|
+
if (wantExtension && !opts.temp) {
|
|
239
|
+
const issue = verifyInstanceExtension(cfg);
|
|
240
|
+
if (issue) {
|
|
241
|
+
console.error(`\n [ERROR] extension 目录未就绪: ${issue}`);
|
|
242
|
+
console.error(` run: mooncat-browser prepare-extension ${cfg.name}`);
|
|
243
|
+
if (cleanup)
|
|
244
|
+
cleanup();
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
82
248
|
try {
|
|
83
249
|
await startService(cfg);
|
|
84
|
-
console.log(`\n service running. Ctrl+C to stop, or: mooncat-browser stop
|
|
250
|
+
console.log(`\n service running. Ctrl+C to stop, or: mooncat-browser stop ${cfg.name}`);
|
|
85
251
|
}
|
|
86
252
|
catch (e) {
|
|
253
|
+
if (cleanup)
|
|
254
|
+
cleanup();
|
|
255
|
+
// 失败时清理 state 文件:BrowserService.start() 在 spawn 前就写了 service.json,
|
|
256
|
+
// 启动失败(EADDRINUSE 等)后若不删,残留 state 会误导 list/status 误报 running。
|
|
257
|
+
try {
|
|
258
|
+
unlinkSyncSafe(serviceStatePath(cfg));
|
|
259
|
+
}
|
|
260
|
+
catch { /* ignore */ }
|
|
87
261
|
console.error(`\n [ERROR] failed to start: ${e.message}`);
|
|
88
262
|
process.exit(1);
|
|
89
263
|
}
|
|
264
|
+
// temp 模式:wrapper 退出时清理(SIGINT/SIGTERM 走 stop(),process.exit 前也清)
|
|
265
|
+
if (cleanup) {
|
|
266
|
+
process.on("exit", () => cleanup());
|
|
267
|
+
}
|
|
90
268
|
});
|
|
91
269
|
// ─── health ─────────────────────────────────────────────────────────────
|
|
92
270
|
program
|
|
93
|
-
.command("health")
|
|
94
|
-
.description("
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
271
|
+
.command("health <name>")
|
|
272
|
+
.description("探测实例的 browserd 是否就绪 + 是否已 open")
|
|
273
|
+
.action(async (name, opts) => {
|
|
274
|
+
let cfg;
|
|
275
|
+
try {
|
|
276
|
+
const handle = findProjectConfig(process.cwd());
|
|
277
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
handleConfigError(e);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const client = new BrowserClient({ port: cfg.rpcPort });
|
|
98
284
|
const h = await client.health();
|
|
99
285
|
if (!h) {
|
|
100
286
|
console.error(`\n[mooncat-browser] browserd not reachable at ${client.baseUrl}`);
|
|
101
|
-
console.error(` hint: run \`mooncat-browser start\` first`);
|
|
287
|
+
console.error(` hint: run \`mooncat-browser start ${name}\` first`);
|
|
102
288
|
process.exit(1);
|
|
103
289
|
}
|
|
104
|
-
printHeader(`health @ ${client.baseUrl}`);
|
|
290
|
+
printHeader(`health @ ${client.baseUrl} (instance ${name})`);
|
|
105
291
|
console.log(JSON.stringify(h, null, 2));
|
|
106
292
|
});
|
|
107
293
|
// ─── stop ───────────────────────────────────────────────────────────────
|
|
108
294
|
program
|
|
109
|
-
.command("stop")
|
|
110
|
-
.description("graceful
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
295
|
+
.command("stop <name>")
|
|
296
|
+
.description("graceful 停止实例(关闭 Chrome + 退出 wrapper,不重启)")
|
|
297
|
+
.action(async (name, opts) => {
|
|
298
|
+
let cfg;
|
|
299
|
+
try {
|
|
300
|
+
const handle = findProjectConfig(process.cwd());
|
|
301
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
handleConfigError(e);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const state = readServiceState(cfg);
|
|
115
308
|
const healthPort = state?.healthPort ?? cfg.healthPort;
|
|
116
|
-
printHeader(`stopping
|
|
117
|
-
// 1) 优先走 wrapper /shutdown
|
|
118
|
-
// healthPort 从 start 时持久化的状态文件读,避免 CLI 覆盖不一致。
|
|
309
|
+
printHeader(`stopping instance "${name}" (rpc :${cfg.rpcPort})`);
|
|
310
|
+
// 1) 优先走 wrapper /shutdown(wrapper 协调 graceful 关闭 + 不重启)
|
|
119
311
|
let viaWrapper = false;
|
|
120
312
|
try {
|
|
121
313
|
const resp = await fetch(`http://127.0.0.1:${healthPort}/shutdown`, { method: "POST" });
|
|
@@ -125,7 +317,7 @@ async function run() {
|
|
|
125
317
|
}
|
|
126
318
|
}
|
|
127
319
|
catch {
|
|
128
|
-
/* wrapper
|
|
320
|
+
/* wrapper 不在,回落到直接关 browserd */
|
|
129
321
|
}
|
|
130
322
|
// 2) wrapper 不在 → 直接发 close 给 browserd
|
|
131
323
|
if (!viaWrapper) {
|
|
@@ -138,14 +330,13 @@ async function run() {
|
|
|
138
330
|
console.error(` [WARN] direct close failed: ${e.message}`);
|
|
139
331
|
}
|
|
140
332
|
}
|
|
141
|
-
// 3) 等 browserd
|
|
333
|
+
// 3) 等 browserd 退出(最多 ~12s)
|
|
142
334
|
const gone = await waitForExit(cfg.rpcPort, 12000);
|
|
143
335
|
console.log(` browserd ${gone ? "stopped" : "still alive (may be restarting or stuck)"}`);
|
|
144
336
|
// 4) 清理可能残留的状态文件
|
|
145
337
|
try {
|
|
146
|
-
if (existsSync(serviceStatePath(cfg
|
|
147
|
-
|
|
148
|
-
unlinkSync(serviceStatePath(cfg.rpcPort));
|
|
338
|
+
if (existsSync(serviceStatePath(cfg))) {
|
|
339
|
+
unlinkSyncSafe(serviceStatePath(cfg));
|
|
149
340
|
console.log(` removed stale state file`);
|
|
150
341
|
}
|
|
151
342
|
}
|
|
@@ -154,30 +345,110 @@ async function run() {
|
|
|
154
345
|
}
|
|
155
346
|
console.log(` done. (Chrome closed gracefully, login state preserved)`);
|
|
156
347
|
});
|
|
348
|
+
// ─── status ─────────────────────────────────────────────────────────────
|
|
349
|
+
program
|
|
350
|
+
.command("status <name>")
|
|
351
|
+
.description("查实例运行状态(走 wrapper /health,含 browserd/chrome pid)")
|
|
352
|
+
.action(async (name, opts) => {
|
|
353
|
+
let cfg;
|
|
354
|
+
try {
|
|
355
|
+
const handle = findProjectConfig(process.cwd());
|
|
356
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
handleConfigError(e);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const state = readServiceState(cfg);
|
|
363
|
+
const healthPort = state?.healthPort ?? cfg.healthPort;
|
|
364
|
+
printHeader(`status of instance "${name}"`);
|
|
365
|
+
try {
|
|
366
|
+
const resp = await fetch(`http://127.0.0.1:${healthPort}/health`, {
|
|
367
|
+
signal: AbortSignal.timeout(2000),
|
|
368
|
+
});
|
|
369
|
+
if (!resp.ok)
|
|
370
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
371
|
+
const s = (await resp.json());
|
|
372
|
+
// 校验响应来自 state 记录的同一进程,否则是僵尸/被占用
|
|
373
|
+
if (state && typeof s.wrapperPid === "number" && s.wrapperPid !== state.pid) {
|
|
374
|
+
console.log(` [STALE] health 端口 ${healthPort} 响应了,但 wrapperPid=${s.wrapperPid} ≠ state.pid=${state.pid}`);
|
|
375
|
+
console.log(` 说明:state 记录的 wrapper(pid=${state.pid})已死,端口被别的进程占用(僵尸或冲突)。`);
|
|
376
|
+
console.log(` 修复:确认无重要进程后,手动清理端口占用 + 删 state 文件,再 start。`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
console.log(JSON.stringify(s, null, 2));
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
console.log(` not running (${e.message})`);
|
|
383
|
+
if (state)
|
|
384
|
+
console.log(` (stale state file: pid=${state.pid}, started ${state.startedAt})`);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
157
388
|
// ─── open ───────────────────────────────────────────────────────────────
|
|
158
389
|
program
|
|
159
|
-
.command("open")
|
|
160
|
-
.description("
|
|
390
|
+
.command("open <name>")
|
|
391
|
+
.description("连接已运行实例,打开/复用浏览器并导航到 URL")
|
|
161
392
|
.requiredOption("-u, --url <url>", "要打开的 URL")
|
|
162
|
-
.option("
|
|
163
|
-
.option("--
|
|
164
|
-
.action(async (opts) => {
|
|
165
|
-
|
|
393
|
+
.option("--headless", "无头模式(仅 CDP 路生效)")
|
|
394
|
+
.option("--route-mode <mode>", "路由模式(auto|cdp|extension)。高危平台用 extension")
|
|
395
|
+
.action(async (name, opts) => {
|
|
396
|
+
let cfg;
|
|
397
|
+
try {
|
|
398
|
+
const handle = findProjectConfig(process.cwd());
|
|
399
|
+
cfg = loadInstance(name, handle, optsToOverrides(opts));
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
handleConfigError(e);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const client = new BrowserClient({ port: cfg.rpcPort });
|
|
166
406
|
const h = await client.health();
|
|
167
407
|
if (!h) {
|
|
168
408
|
console.error(`\n[mooncat-browser] browserd not reachable at ${client.baseUrl}`);
|
|
169
|
-
console.error(` hint: run \`mooncat-browser start\` first`);
|
|
409
|
+
console.error(` hint: run \`mooncat-browser start ${name}\` first`);
|
|
170
410
|
process.exit(1);
|
|
171
411
|
}
|
|
172
412
|
if (!h.open && !h.browserOpen) {
|
|
173
|
-
|
|
174
|
-
|
|
413
|
+
// routeMode:CLI 显式给 > 实例配置 routeMode > 不传(browsed 用 auto 默认)
|
|
414
|
+
const openOpts = { headless: !!opts.headless };
|
|
415
|
+
const rm = opts.routeMode || cfg.routeMode;
|
|
416
|
+
if (rm)
|
|
417
|
+
openOpts.routeMode = rm;
|
|
418
|
+
await client.open(openOpts);
|
|
419
|
+
console.log(` browser opened${rm ? ` (routeMode=${rm})` : ""}`);
|
|
175
420
|
}
|
|
176
421
|
const tab = await client.newTab({ url: opts.url });
|
|
177
422
|
printHeader(`opened`);
|
|
178
|
-
console.log(`
|
|
179
|
-
console.log(`
|
|
180
|
-
console.log(`
|
|
423
|
+
console.log(` instance : ${name}`);
|
|
424
|
+
console.log(` url : ${opts.url}`);
|
|
425
|
+
console.log(` pageHandle : ${JSON.stringify(tab.pageHandle)}`);
|
|
426
|
+
console.log(` reused : ${tab.reused ?? false}`);
|
|
427
|
+
});
|
|
428
|
+
// ─── list ───────────────────────────────────────────────────────────────
|
|
429
|
+
program
|
|
430
|
+
.command("list")
|
|
431
|
+
.description("列出项目配置里所有实例 + 运行状态")
|
|
432
|
+
.action(async () => {
|
|
433
|
+
let handle;
|
|
434
|
+
try {
|
|
435
|
+
handle = findProjectConfig(process.cwd());
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
handleConfigError(e);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const names = listInstanceNames(handle);
|
|
442
|
+
printHeader(`instances in ${handle.configPath}`);
|
|
443
|
+
if (!names.length) {
|
|
444
|
+
console.log(` (no instances defined)`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
for (const n of names) {
|
|
448
|
+
const cfg = loadInstance(n, handle);
|
|
449
|
+
const flag = await realInstanceStatus(cfg);
|
|
450
|
+
console.log(` ${n.padEnd(16)} rpc:${cfg.rpcPort} health:${cfg.healthPort} [${flag}]`);
|
|
451
|
+
}
|
|
181
452
|
});
|
|
182
453
|
// ─── install-extension ──────────────────────────────────────────────────
|
|
183
454
|
program
|
|
@@ -196,15 +467,16 @@ async function run() {
|
|
|
196
467
|
console.log(` 1. enable "Developer mode" (top right)`);
|
|
197
468
|
console.log(` 2. click "Load unpacked"`);
|
|
198
469
|
console.log(` 3. select the directory above`);
|
|
199
|
-
console.log(`\n
|
|
470
|
+
console.log(`\n 装扩展后,重启浏览器(close + open)才会切到 extension 路由。`);
|
|
471
|
+
console.log(` 注意:第一版 extension 路是单实例(WS 端口固定)。多实例请走 CDP 路。`);
|
|
200
472
|
});
|
|
201
473
|
// ─── skills ─────────────────────────────────────────────────────────────
|
|
202
474
|
program
|
|
203
475
|
.command("skills")
|
|
204
|
-
.description("安装/更新内置 skills
|
|
205
|
-
.option("-s, --skill <name>", "只安装指定 skill
|
|
206
|
-
.option("-t, --target <dir>", "
|
|
207
|
-
.option("--list", "只列出包内的 skills
|
|
476
|
+
.description("安装/更新内置 skills 到项目本地 .agents/skills(覆盖同名,幂等可重复执行)")
|
|
477
|
+
.option("-s, --skill <name>", "只安装指定 skill(默认全部)")
|
|
478
|
+
.option("-t, --target <dir>", "自定义目标目录(默认 .agents/skills)")
|
|
479
|
+
.option("--list", "只列出包内的 skills,不拷贝")
|
|
208
480
|
.action((opts) => {
|
|
209
481
|
const srcRoot = skillsSourceDir();
|
|
210
482
|
if (!existsSync(srcRoot)) {
|
|
@@ -212,7 +484,6 @@ async function run() {
|
|
|
212
484
|
console.error(` hint: 包发布时可能漏了 skills/ 目录`);
|
|
213
485
|
process.exit(1);
|
|
214
486
|
}
|
|
215
|
-
// 列出源里的 skill 子目录(每个含 SKILL.md)
|
|
216
487
|
const names = readdirSync(srcRoot, { withFileTypes: true })
|
|
217
488
|
.filter((d) => d.isDirectory())
|
|
218
489
|
.filter((d) => existsSync(join(srcRoot, d.name, "SKILL.md")))
|
|
@@ -236,7 +507,6 @@ async function run() {
|
|
|
236
507
|
for (const n of requested) {
|
|
237
508
|
const src = join(srcRoot, n);
|
|
238
509
|
const dest = join(destRoot, n);
|
|
239
|
-
// 先清空目标 skill 目录再拷贝,保证与源完全一致(删除源里已移除的旧文件,如重命名后的残留)
|
|
240
510
|
rmSync(dest, { recursive: true, force: true });
|
|
241
511
|
cpSync(src, dest, { recursive: true, force: true });
|
|
242
512
|
console.log(` [ok] ${n} -> ${dest}`);
|
|
@@ -245,17 +515,25 @@ async function run() {
|
|
|
245
515
|
});
|
|
246
516
|
await program.parseAsync(process.argv);
|
|
247
517
|
}
|
|
248
|
-
/**
|
|
518
|
+
/** 安全 unlink(独立函数,方便 stop 里 import 前已可用)。 */
|
|
519
|
+
function unlinkSyncSafe(p) {
|
|
520
|
+
try {
|
|
521
|
+
rmSync(p, { force: true });
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
/* ignore */
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** 包内 skills 源目录:dist/cli.js -> ../skills;源码运行 -> ../../skills。 */
|
|
249
528
|
function skillsSourceDir() {
|
|
250
529
|
return process.env.MOONCAT_BROWSER_SKILLS_SRC || join(__dirname, "..", "skills");
|
|
251
530
|
}
|
|
252
|
-
/** skills
|
|
531
|
+
/** skills 安装默认目标:项目本地 .agents/skills(cwd 下)。
|
|
532
|
+
* 与 mooncat-browser "项目级、不写全局" 的定位一致——skills 跟着项目走,不污染全局。 */
|
|
253
533
|
function defaultSkillsTarget() {
|
|
254
|
-
return join(
|
|
534
|
+
return join(process.cwd(), ".agents", "skills");
|
|
255
535
|
}
|
|
256
536
|
function getVersion() {
|
|
257
|
-
// 编译后 dist/cli.js 读 ../package.json;源码运行时读 ../../package.json
|
|
258
|
-
// 统一用相对包根查找(容忍 dist/ 与 src/ 两种位置)
|
|
259
537
|
const candidates = [
|
|
260
538
|
join(__dirname, "..", "package.json"),
|
|
261
539
|
join(__dirname, "..", "..", "package.json"),
|