@chrome-in-harness/launcher 0.0.0-stage → 0.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/dist/bin.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from './cli.js';
3
+ runCli().catch((error) => {
4
+ console.error(`launcher failed: ${error instanceof Error ? error.message : String(error)}`);
5
+ process.exitCode = 1;
6
+ });
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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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.1.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
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.