@chrome-in-harness/launcher 0.0.0-stage → 0.2.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 +74 -2
- package/dist/bin.js +6 -0
- package/dist/cli.js +251 -0
- package/dist/service.js +153 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chrome-in-harness/launcher
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[中文](#中文) | [English](#english)
|
|
4
|
+
|
|
5
|
+
`chrome-in-harness` CLI:一条命令完成「启动本地 server + 写 MCP 客户端配置」,并提供可选的常驻服务注册。
|
|
6
|
+
|
|
7
|
+
> npm 包名是 `@chrome-in-harness/launcher`,bin 名是 `chrome-in-harness`,因此用法是 `npx @chrome-in-harness/launcher <command>`。
|
|
8
|
+
|
|
9
|
+
## 中文
|
|
10
|
+
|
|
11
|
+
### 命令
|
|
12
|
+
|
|
13
|
+
| 命令 | 作用 |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `start` | 前台启动本地 server(Ctrl-C 退出);已运行则复用;同时向 OpenCode 全局配置写入 MCP 条目 |
|
|
16
|
+
| `doctor` | 环境自检:server 是否运行、扩展是否连通(真发一次 `ping`)、检测常见 MCP 客户端配置文件 |
|
|
17
|
+
| `service install` | 注册常驻服务:macOS launchd / Linux systemd --user(登录自启 + 崩溃自动拉起) |
|
|
18
|
+
| `service status` | 查看服务安装状态与 server 运行状态 |
|
|
19
|
+
| `service uninstall` | 停止并移除常驻服务 |
|
|
20
|
+
| (无参数) | 帮助信息 |
|
|
21
|
+
|
|
22
|
+
### 实现要点
|
|
23
|
+
|
|
24
|
+
- **server 入口解析**:`import.meta.resolve('@chrome-in-harness/server')` 经 npm 依赖解析拿到入口文件,不硬编码路径
|
|
25
|
+
- **MCP 配置写入**(`writeOpencodeConfig`):
|
|
26
|
+
- 已有同名 `chrome-in-harness` 条目 → 原样保留(逐字节不动)
|
|
27
|
+
- 配置文件不存在 → 创建配置目录并写入(文件权限 0600)
|
|
28
|
+
- 配置文件非法(坏 JSON / 非对象 / `mcp` 段非对象)→ **报错退出,不覆盖原文件**
|
|
29
|
+
- **常驻服务**(`src/service.ts`,文件生成是纯函数、系统命令可注入,全程单测覆盖):
|
|
30
|
+
- macOS:`~/Library/LaunchAgents/com.chrome-in-harness.server.plist`(`RunAtLoad` + `KeepAlive`),日志 `~/Library/Logs/chrome-in-harness-server.log`
|
|
31
|
+
- Linux:`~/.config/systemd/user/chrome-in-harness.service`(`Restart=always`),日志走 journalctl
|
|
32
|
+
- 已安装时再次 `install` 拒绝覆盖;`launchctl load` 失败会回滚已写的 plist,不留半安装状态
|
|
33
|
+
- Windows 不支持,明确报错并建议 `start` + 进程守护
|
|
34
|
+
- **CLI 入口拆分**:执行逻辑在 `src/cli.ts`(可被测试 import 且不自动执行),`src/bin.ts` 只做入口引导——通过 symlink 安装、路径含空格也能正确判定入口
|
|
35
|
+
|
|
36
|
+
### 与其他包的关系
|
|
37
|
+
|
|
38
|
+
launcher 只负责生命周期与配置:server 进程来自 `@chrome-in-harness/server`,浏览器操作全部在扩展内完成。launcher 不是源码 watcher,迭代开发请用仓库根的 `npm run dev:server`。
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## English
|
|
43
|
+
|
|
44
|
+
The `chrome-in-harness` CLI: one command to start the local server and register the MCP client config, plus an opt-in persistent-service installer.
|
|
45
|
+
|
|
46
|
+
> The npm package is `@chrome-in-harness/launcher` while the bin is `chrome-in-harness`, so usage is `npx @chrome-in-harness/launcher <command>`.
|
|
47
|
+
|
|
48
|
+
### Commands
|
|
49
|
+
|
|
50
|
+
| Command | What it does |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `start` | Start the local server in the foreground (Ctrl-C to stop); reuses an already-running server; also writes the MCP entry into the OpenCode global config |
|
|
53
|
+
| `doctor` | Environment check: is the server running, is the extension connected (performs a real `ping`), and detects common MCP client config files |
|
|
54
|
+
| `service install` | Install a persistent user service: launchd on macOS / systemd --user on Linux (start at login + auto-restart on crash) |
|
|
55
|
+
| `service status` | Show service installation and server running state |
|
|
56
|
+
| `service uninstall` | Stop and remove the persistent service |
|
|
57
|
+
| (no args) | Help |
|
|
58
|
+
|
|
59
|
+
### Implementation notes
|
|
60
|
+
|
|
61
|
+
- **Server entry resolution**: `import.meta.resolve('@chrome-in-harness/server')` resolves the entry via npm dependency resolution — no hardcoded paths
|
|
62
|
+
- **MCP config write** (`writeOpencodeConfig`):
|
|
63
|
+
- An existing `chrome-in-harness` entry is preserved byte for byte
|
|
64
|
+
- Missing config → the directory is created and the file written (mode 0600)
|
|
65
|
+
- Invalid config (broken JSON / non-object / non-object `mcp` section) → **errors out without touching the original file**
|
|
66
|
+
- **Persistent service** (`src/service.ts`; file generation is a pure function and system commands are injectable, fully unit-tested):
|
|
67
|
+
- macOS: `~/Library/LaunchAgents/com.chrome-in-harness.server.plist` (`RunAtLoad` + `KeepAlive`), logs at `~/Library/Logs/chrome-in-harness-server.log`
|
|
68
|
+
- Linux: `~/.config/systemd/user/chrome-in-harness.service` (`Restart=always`), logs via journalctl
|
|
69
|
+
- Re-running `install` while installed refuses to overwrite; a failed `launchctl load` rolls back the written plist, leaving no half-installed state
|
|
70
|
+
- Windows is unsupported and fails with an explicit message suggesting `start` plus a process supervisor
|
|
71
|
+
- **CLI entry split**: the logic lives in `src/cli.ts` (importable by tests without side effects) while `src/bin.ts` is only the bootstrap — installs via symlinks and paths containing spaces are handled correctly
|
|
72
|
+
|
|
73
|
+
### Relation to other packages
|
|
74
|
+
|
|
75
|
+
The launcher only manages lifecycle and configuration: the server process comes from `@chrome-in-harness/server`, and all browser work happens inside the extension. The launcher is not a source watcher — use `npm run dev:server` from the repo root for development.
|
package/dist/bin.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* chrome-in-harness launcher:一条命令完成「检查/引导装扩展 + 起 server + 写 MCP 配置」。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* npx @chrome-in-harness/launcher 检查环境并给出下一步
|
|
7
|
+
* npx @chrome-in-harness/launcher start 启动本地 server(前台,Ctrl-C 退出)
|
|
8
|
+
* npx @chrome-in-harness/launcher doctor 完整环境自检
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { installService, launchdPlistPath, systemdUnitPath, uninstallService } from './service.js';
|
|
18
|
+
const SERVER_URL = 'http://127.0.0.1:12306/mcp';
|
|
19
|
+
const WS_PORT = 8765;
|
|
20
|
+
const HTTP_PORT = 12306;
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const cmd = argv[2] ?? 'help';
|
|
23
|
+
if (cmd === 'start' || cmd === 'doctor')
|
|
24
|
+
return cmd;
|
|
25
|
+
if (cmd === 'service') {
|
|
26
|
+
const action = argv[3];
|
|
27
|
+
if (action === 'install' || action === 'uninstall' || action === 'status') {
|
|
28
|
+
return { service: action };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return 'help';
|
|
32
|
+
}
|
|
33
|
+
function log(msg) {
|
|
34
|
+
console.log(msg);
|
|
35
|
+
}
|
|
36
|
+
/** 探测 MCP 端口是否已有 server 监听(TCP 握手比 MCP 协议探测更可靠)。 */
|
|
37
|
+
function isServerRunning() {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const socket = new net.Socket();
|
|
40
|
+
socket.setTimeout(1000);
|
|
41
|
+
socket.once('connect', () => {
|
|
42
|
+
socket.destroy();
|
|
43
|
+
resolve(true);
|
|
44
|
+
});
|
|
45
|
+
socket.once('timeout', () => {
|
|
46
|
+
socket.destroy();
|
|
47
|
+
resolve(false);
|
|
48
|
+
});
|
|
49
|
+
socket.once('error', () => {
|
|
50
|
+
socket.destroy();
|
|
51
|
+
resolve(false);
|
|
52
|
+
});
|
|
53
|
+
socket.connect(HTTP_PORT, '127.0.0.1');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function detectClients() {
|
|
57
|
+
const home = process.env['HOME'] ?? '';
|
|
58
|
+
const cwd = process.cwd();
|
|
59
|
+
const opencodeGlobal = path.join(home, '.config', 'opencode', 'opencode.json');
|
|
60
|
+
const opencodeJson = path.join(cwd, 'opencode.json');
|
|
61
|
+
const opencodeJsonc = path.join(cwd, 'opencode.jsonc');
|
|
62
|
+
const mcpJson = path.join(cwd, '.mcp.json');
|
|
63
|
+
const claudeConfig = path.join(home, '.claude.json');
|
|
64
|
+
const found = [];
|
|
65
|
+
for (const p of [opencodeGlobal, opencodeJson, opencodeJsonc, mcpJson, claudeConfig]) {
|
|
66
|
+
if (existsSync(p))
|
|
67
|
+
found.push(p);
|
|
68
|
+
}
|
|
69
|
+
if (found.length === 0) {
|
|
70
|
+
log(' 未发现现有 MCP 客户端配置文件(opencode / .mcp.json / claude)。');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
log(` 发现配置文件:${found.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** 检测扩展是否已装:通过 MCP tools/call ping 验证全链路(server+扩展)。 */
|
|
77
|
+
async function pingExtension() {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(SERVER_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
Accept: 'application/json, text/event-stream',
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
jsonrpc: '2.0',
|
|
87
|
+
id: 2,
|
|
88
|
+
method: 'tools/call',
|
|
89
|
+
params: { name: 'ping', arguments: {} },
|
|
90
|
+
}),
|
|
91
|
+
signal: AbortSignal.timeout(3000),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
return false;
|
|
95
|
+
const body = (await res.json());
|
|
96
|
+
const text = body.result?.content?.[0]?.text ?? '';
|
|
97
|
+
// ping 成功返回 {version, userAgent}
|
|
98
|
+
return text.includes('version') || text.includes('userAgent');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** 合并 opencode 配置:插入 chrome-in-harness MCP 条目(不覆盖已有同名)。返回新配置对象(纯逻辑)。 */
|
|
105
|
+
export function mergeOpencodeConfig(existing, url) {
|
|
106
|
+
const base = existing === undefined ? {} : { ...existing };
|
|
107
|
+
const currentMcp = base['mcp'];
|
|
108
|
+
if (currentMcp !== undefined && !isConfigObject(currentMcp)) {
|
|
109
|
+
throw new Error('OpenCode configuration mcp must be an object; the original file was not changed.');
|
|
110
|
+
}
|
|
111
|
+
const mcp = currentMcp ?? {};
|
|
112
|
+
if (mcp['chrome-in-harness'] !== undefined)
|
|
113
|
+
return { config: base, added: false };
|
|
114
|
+
return {
|
|
115
|
+
config: { ...base, mcp: { ...mcp, 'chrome-in-harness': { type: 'remote', url, enabled: true } } },
|
|
116
|
+
added: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function isConfigObject(value) {
|
|
120
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
121
|
+
}
|
|
122
|
+
async function readOpencodeConfig(configPath) {
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = await readFile(configPath, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (isConfigObject(error) && error['code'] === 'ENOENT')
|
|
129
|
+
return {};
|
|
130
|
+
throw new Error(`Cannot read OpenCode configuration at ${configPath}; the original file was not changed.`, { cause: error });
|
|
131
|
+
}
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(raw);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new Error(`Invalid JSON in OpenCode configuration at ${configPath}; the original file was not changed.`, { cause: error });
|
|
138
|
+
}
|
|
139
|
+
if (!isConfigObject(parsed)) {
|
|
140
|
+
throw new Error(`OpenCode configuration at ${configPath} must be an object; the original file was not changed.`);
|
|
141
|
+
}
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
/** 往 opencode 全局配置写入 MCP 条目(不覆盖已有同名条目)。 */
|
|
145
|
+
export async function writeOpencodeConfig(configPath = path.join(homedir(), '.config', 'opencode', 'opencode.json')) {
|
|
146
|
+
const config = await readOpencodeConfig(configPath);
|
|
147
|
+
const { config: merged, added } = mergeOpencodeConfig(config, SERVER_URL);
|
|
148
|
+
if (!added) {
|
|
149
|
+
log(' opencode 配置已包含 chrome-in-harness,跳过。');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
153
|
+
await writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
154
|
+
log(` 已写入 opencode 全局配置: ${configPath}`);
|
|
155
|
+
}
|
|
156
|
+
async function doctor() {
|
|
157
|
+
log('chrome-in-harness doctor');
|
|
158
|
+
log('=======================');
|
|
159
|
+
const running = await isServerRunning();
|
|
160
|
+
log(running ? `[✓] server 已在运行 (${SERVER_URL})` : '[✗] server 未运行');
|
|
161
|
+
if (running) {
|
|
162
|
+
const ok = await pingExtension();
|
|
163
|
+
log(ok ? '[✓] 扩展已连接(ping 通过)' : '[✗] 扩展未连接(请确认已加载 Chrome 扩展)');
|
|
164
|
+
}
|
|
165
|
+
await detectClients();
|
|
166
|
+
log('\n下一步:');
|
|
167
|
+
log(' 1. npx @chrome-in-harness/launcher start # 启动 server');
|
|
168
|
+
log(' 2. 在 Chrome 加载扩展(chrome://extensions → 开发者模式 → 加载已解压)');
|
|
169
|
+
log(' 3. npx @chrome-in-harness/launcher doctor # 复检全链路');
|
|
170
|
+
}
|
|
171
|
+
async function start() {
|
|
172
|
+
if (await isServerRunning()) {
|
|
173
|
+
log(`server 已在运行:${SERVER_URL}`);
|
|
174
|
+
await writeOpencodeConfig();
|
|
175
|
+
log('如扩展已加载,直接在客户端里调 ping 即可。');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
log('启动 chrome-in-harness server …');
|
|
179
|
+
// 解析 @chrome-in-harness/server 的入口文件(npm 依赖解析,无需硬编码路径)
|
|
180
|
+
let bin;
|
|
181
|
+
try {
|
|
182
|
+
bin = fileURLToPath(import.meta.resolve('@chrome-in-harness/server'));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
log('未找到 @chrome-in-harness/server,请先安装依赖:npm i @chrome-in-harness/server');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
log(`使用入口: ${bin}`);
|
|
189
|
+
await writeOpencodeConfig();
|
|
190
|
+
const child = spawn(process.execPath, [bin], {
|
|
191
|
+
stdio: 'inherit',
|
|
192
|
+
env: { ...process.env, WS_PORT: String(WS_PORT), HTTP_PORT: String(HTTP_PORT) },
|
|
193
|
+
});
|
|
194
|
+
child.on('exit', (code) => {
|
|
195
|
+
log(`\nserver 已退出 (code ${code ?? '?'})`);
|
|
196
|
+
process.exit(code ?? 0);
|
|
197
|
+
});
|
|
198
|
+
log('server 启动中。Ctrl-C 停止。');
|
|
199
|
+
}
|
|
200
|
+
/** service install 的 node / server 入口解析:node 用当前解释器,server 走 npm 依赖解析。 */
|
|
201
|
+
function resolveServiceEntry() {
|
|
202
|
+
const serverEntry = fileURLToPath(import.meta.resolve('@chrome-in-harness/server'));
|
|
203
|
+
return { nodeBin: process.execPath, serverEntry };
|
|
204
|
+
}
|
|
205
|
+
async function service(action) {
|
|
206
|
+
if (action === 'status') {
|
|
207
|
+
const running = await isServerRunning();
|
|
208
|
+
const installed = existsSync(launchdPlistPath()) || existsSync(systemdUnitPath());
|
|
209
|
+
log(`service: ${installed ? '已安装(开机自启)' : '未安装'}`);
|
|
210
|
+
log(`server: ${running ? `运行中 (${SERVER_URL})` : '未运行'}`);
|
|
211
|
+
if (!installed) {
|
|
212
|
+
log(' 提示:npx @chrome-in-harness/launcher service install 可注册常驻服务。');
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (action === 'uninstall') {
|
|
217
|
+
await uninstallService();
|
|
218
|
+
log('service 已卸载(文件已删除,服务已停止)。');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const { nodeBin, serverEntry } = resolveServiceEntry();
|
|
222
|
+
await installService({ nodeBin, serverEntry, wsPort: WS_PORT, httpPort: HTTP_PORT });
|
|
223
|
+
await writeOpencodeConfig();
|
|
224
|
+
const logHint = process.platform === 'darwin'
|
|
225
|
+
? `日志: ${path.join(homedir(), 'Library', 'Logs', 'chrome-in-harness-server.log')}`
|
|
226
|
+
: '日志: journalctl --user -u chrome-in-harness.service';
|
|
227
|
+
log('service 已安装并启动:登录自启 + 崩溃自动拉起。');
|
|
228
|
+
log(` ${logHint}`);
|
|
229
|
+
}
|
|
230
|
+
export async function runCli() {
|
|
231
|
+
const cmd = parseArgs(process.argv);
|
|
232
|
+
if (cmd === 'start')
|
|
233
|
+
await start();
|
|
234
|
+
else if (cmd === 'doctor')
|
|
235
|
+
await doctor();
|
|
236
|
+
else if (typeof cmd === 'object')
|
|
237
|
+
await service(cmd.service);
|
|
238
|
+
else {
|
|
239
|
+
log('chrome-in-harness — 通过 Chrome 扩展控制真实 Chrome profile 的浏览器 MCP');
|
|
240
|
+
log('');
|
|
241
|
+
log('用法:');
|
|
242
|
+
log(' npx @chrome-in-harness/launcher doctor # 环境自检');
|
|
243
|
+
log(' npx @chrome-in-harness/launcher start # 前台启动 server(Ctrl-C 退出)');
|
|
244
|
+
log(' npx @chrome-in-harness/launcher service install # 注册常驻服务(launchd / systemd --user)');
|
|
245
|
+
log(' npx @chrome-in-harness/launcher service status # 查看服务与运行状态');
|
|
246
|
+
log(' npx @chrome-in-harness/launcher service uninstall # 移除常驻服务');
|
|
247
|
+
log('');
|
|
248
|
+
log('扩展安装:chrome://extensions → 开发者模式 → 加载已解压的扩展程序');
|
|
249
|
+
log(' (从 GitHub Releases 下载 chrome-in-harness-extension.zip,或本仓库 packages/extension/.output/chrome-mv3)');
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 常驻服务安装:把本地 server 注册为登录自启 + 崩溃自动拉起的用户级服务。
|
|
3
|
+
* - macOS:launchd 用户代理(~/Library/LaunchAgents,RunAtLoad + KeepAlive)
|
|
4
|
+
* - Linux:systemd 用户单元(~/.config/systemd/user,Restart=always)
|
|
5
|
+
* 文件生成是纯函数(可单测);install/uninstall 只做「写文件 + 调系统命令」的薄封装,
|
|
6
|
+
* 系统命令通过 run 注入,测试不打真 launchctl/systemctl。
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
export const LAUNCHD_LABEL = 'com.chrome-in-harness.server';
|
|
16
|
+
export const SYSTEMD_NAME = 'chrome-in-harness.service';
|
|
17
|
+
export const defaultRunner = async (file, args) => {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout, stderr } = await execFileAsync(file, args);
|
|
20
|
+
return { code: 0, stdout, stderr };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const e = error;
|
|
24
|
+
return { code: e.code ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? e.message };
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export function launchdPlistPath(home = homedir()) {
|
|
28
|
+
return path.join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
29
|
+
}
|
|
30
|
+
export function launchdLogPath(home = homedir()) {
|
|
31
|
+
return path.join(home, 'Library', 'Logs', 'chrome-in-harness-server.log');
|
|
32
|
+
}
|
|
33
|
+
export function systemdUnitPath(home = homedir()) {
|
|
34
|
+
return path.join(home, '.config', 'systemd', 'user', SYSTEMD_NAME);
|
|
35
|
+
}
|
|
36
|
+
export function buildLaunchdPlist(input) {
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array>
|
|
44
|
+
<string>${input.nodeBin}</string>
|
|
45
|
+
<string>${input.serverEntry}</string>
|
|
46
|
+
</array>
|
|
47
|
+
<key>EnvironmentVariables</key>
|
|
48
|
+
<dict>
|
|
49
|
+
<key>WS_PORT</key><integer>${input.wsPort}</integer>
|
|
50
|
+
<key>HTTP_PORT</key><integer>${input.httpPort}</integer>
|
|
51
|
+
</dict>
|
|
52
|
+
<key>RunAtLoad</key><true/>
|
|
53
|
+
<key>KeepAlive</key><true/>
|
|
54
|
+
<key>StandardOutPath</key><string>${input.logFile}</string>
|
|
55
|
+
<key>StandardErrorPath</key><string>${input.logFile}</string>
|
|
56
|
+
</dict>
|
|
57
|
+
</plist>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
export function buildSystemdUnit(input) {
|
|
61
|
+
return `[Unit]
|
|
62
|
+
Description=chrome-in-harness local MCP server
|
|
63
|
+
After=network.target
|
|
64
|
+
|
|
65
|
+
[Service]
|
|
66
|
+
ExecStart=${input.nodeBin} ${input.serverEntry}
|
|
67
|
+
Environment=WS_PORT=${input.wsPort}
|
|
68
|
+
Environment=HTTP_PORT=${input.httpPort}
|
|
69
|
+
Restart=always
|
|
70
|
+
RestartSec=2
|
|
71
|
+
|
|
72
|
+
[Install]
|
|
73
|
+
WantedBy=default.target
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
function normalizePlatform(platform) {
|
|
77
|
+
if (platform === 'darwin')
|
|
78
|
+
return 'darwin';
|
|
79
|
+
if (platform === 'linux')
|
|
80
|
+
return 'linux';
|
|
81
|
+
return 'unsupported';
|
|
82
|
+
}
|
|
83
|
+
export async function installService(options) {
|
|
84
|
+
const platform = normalizePlatform(options.platform ?? process.platform);
|
|
85
|
+
const run = options.run ?? defaultRunner;
|
|
86
|
+
if (platform === 'unsupported') {
|
|
87
|
+
throw new Error(`service install is not supported on ${String(options.platform ?? process.platform)}; ` +
|
|
88
|
+
'run "chrome-in-harness start" in a long-lived terminal instead.');
|
|
89
|
+
}
|
|
90
|
+
if (platform === 'darwin') {
|
|
91
|
+
const home = options.home ?? homedir();
|
|
92
|
+
const plist = launchdPlistPath(home);
|
|
93
|
+
if (existsSync(plist)) {
|
|
94
|
+
throw new Error(`service is already installed at ${plist}; run "service uninstall" first.`);
|
|
95
|
+
}
|
|
96
|
+
await mkdir(path.dirname(plist), { recursive: true });
|
|
97
|
+
await writeFile(plist, buildLaunchdPlist({
|
|
98
|
+
nodeBin: options.nodeBin,
|
|
99
|
+
serverEntry: options.serverEntry,
|
|
100
|
+
wsPort: options.wsPort,
|
|
101
|
+
httpPort: options.httpPort,
|
|
102
|
+
logFile: launchdLogPath(home),
|
|
103
|
+
}));
|
|
104
|
+
const result = await run('launchctl', ['load', plist]);
|
|
105
|
+
if (result.code !== 0) {
|
|
106
|
+
// 安装半途失败:清掉 plist,回到未安装状态,不留双态。
|
|
107
|
+
await rm(plist, { force: true });
|
|
108
|
+
throw new Error(`launchctl load failed: ${result.stderr.trim()}`);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const home = options.home ?? homedir();
|
|
113
|
+
const unit = systemdUnitPath(home);
|
|
114
|
+
if (existsSync(unit)) {
|
|
115
|
+
throw new Error(`service is already installed at ${unit}; run "service uninstall" first.`);
|
|
116
|
+
}
|
|
117
|
+
await mkdir(path.dirname(unit), { recursive: true });
|
|
118
|
+
await writeFile(unit, buildSystemdUnit({
|
|
119
|
+
nodeBin: options.nodeBin,
|
|
120
|
+
serverEntry: options.serverEntry,
|
|
121
|
+
wsPort: options.wsPort,
|
|
122
|
+
httpPort: options.httpPort,
|
|
123
|
+
}));
|
|
124
|
+
const reload = await run('systemctl', ['--user', 'daemon-reload']);
|
|
125
|
+
const enable = await run('systemctl', ['--user', 'enable', '--now', SYSTEMD_NAME]);
|
|
126
|
+
if (reload.code !== 0 || enable.code !== 0) {
|
|
127
|
+
await rm(unit, { force: true });
|
|
128
|
+
await run('systemctl', ['--user', 'daemon-reload']).catch(() => undefined);
|
|
129
|
+
throw new Error(`systemctl enable failed: ${(enable.stderr || reload.stderr).trim()}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function uninstallService(options = {}) {
|
|
133
|
+
const platform = normalizePlatform(options.platform ?? process.platform);
|
|
134
|
+
const run = options.run ?? defaultRunner;
|
|
135
|
+
if (platform === 'unsupported') {
|
|
136
|
+
throw new Error(`service uninstall is not supported on ${String(options.platform ?? process.platform)}.`);
|
|
137
|
+
}
|
|
138
|
+
if (platform === 'darwin') {
|
|
139
|
+
const plist = launchdPlistPath(options.home ?? homedir());
|
|
140
|
+
if (!existsSync(plist))
|
|
141
|
+
return;
|
|
142
|
+
// unload 尽力而为:服务本来没加载也算卸载成功。
|
|
143
|
+
await run('launchctl', ['unload', plist]);
|
|
144
|
+
await rm(plist, { force: true });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const unit = systemdUnitPath(options.home ?? homedir());
|
|
148
|
+
if (!existsSync(unit))
|
|
149
|
+
return;
|
|
150
|
+
await run('systemctl', ['--user', 'disable', '--now', SYSTEMD_NAME]);
|
|
151
|
+
await rm(unit, { force: true });
|
|
152
|
+
await run('systemctl', ['--user', 'daemon-reload']);
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrome-in-harness/launcher",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"chrome-in-harness": "./dist/bin.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"pretest": "npm run build",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@chrome-in-harness/server": "^0.2.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.0.0",
|
|
24
|
+
"typescript": "^5.6.3",
|
|
25
|
+
"vitest": "^5.0.1"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/hcfw007/chrome-in-harness.git"
|
|
31
|
+
}
|
|
32
|
+
}
|