@jackwener/opencli 1.2.5 → 1.3.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/.github/workflows/release.yml +7 -0
- package/README.md +5 -64
- package/README.zh-CN.md +4 -48
- package/SKILL.md +1 -3
- package/TESTING.md +87 -69
- package/dist/browser/cdp.js +5 -4
- package/dist/browser/daemon-client.js +9 -3
- package/dist/browser/discover.js +3 -1
- package/dist/browser/dom-helpers.d.ts +8 -0
- package/dist/browser/dom-helpers.js +33 -0
- package/dist/browser/page.js +9 -5
- package/dist/cli.js +2 -9
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +53 -6
- package/dist/doctor.js +14 -2
- package/dist/doctor.test.js +15 -19
- package/docs/developer/testing.md +87 -69
- package/docs/guide/browser-bridge.md +0 -1
- package/docs/guide/getting-started.md +1 -1
- package/docs/guide/troubleshooting.md +1 -1
- package/docs/index.md +1 -1
- package/docs/zh/guide/browser-bridge.md +0 -1
- package/extension/dist/background.js +5 -14
- package/extension/manifest.json +1 -1
- package/extension/package.json +1 -1
- package/extension/src/background.ts +7 -23
- package/extension/src/cdp.ts +5 -2
- package/package.json +1 -1
- package/src/browser/cdp.ts +5 -3
- package/src/browser/daemon-client.ts +9 -3
- package/src/browser/discover.ts +3 -1
- package/src/browser/dom-helpers.ts +34 -0
- package/src/browser/page.ts +9 -4
- package/src/cli.ts +2 -10
- package/src/daemon.ts +57 -7
- package/src/doctor.test.ts +17 -25
- package/src/doctor.ts +14 -2
- package/dist/setup.d.ts +0 -10
- package/dist/setup.js +0 -66
- package/src/setup.ts +0 -69
|
@@ -88,7 +88,7 @@ function scheduleReconnect(): void {
|
|
|
88
88
|
// ─── Automation window isolation ─────────────────────────────────────
|
|
89
89
|
// All opencli operations happen in a dedicated Chrome window so the
|
|
90
90
|
// user's active browsing session is never touched.
|
|
91
|
-
// The window auto-closes after
|
|
91
|
+
// The window auto-closes after 120s of idle (no commands).
|
|
92
92
|
|
|
93
93
|
type AutomationSession = {
|
|
94
94
|
windowId: number;
|
|
@@ -247,7 +247,6 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
247
247
|
if (tabId !== undefined) {
|
|
248
248
|
try {
|
|
249
249
|
const tab = await chrome.tabs.get(tabId);
|
|
250
|
-
console.log(`[opencli] resolveTabId: explicit tabId=${tabId}, url=${tab.url}`);
|
|
251
250
|
if (isDebuggableUrl(tab.url)) return tabId;
|
|
252
251
|
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
|
253
252
|
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
@@ -260,42 +259,27 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
260
259
|
// Get (or create) the automation window
|
|
261
260
|
const windowId = await getAutomationWindow(workspace);
|
|
262
261
|
|
|
263
|
-
// Prefer an existing debuggable tab
|
|
262
|
+
// Prefer an existing debuggable tab
|
|
264
263
|
const tabs = await chrome.tabs.query({ windowId });
|
|
265
264
|
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
|
|
266
|
-
if (debuggableTab?.id)
|
|
267
|
-
console.log(`[opencli] resolveTabId: found debuggable tab ${debuggableTab.id} (${debuggableTab.url})`);
|
|
268
|
-
return debuggableTab.id;
|
|
269
|
-
}
|
|
270
|
-
console.warn(`[opencli] resolveTabId: no debuggable tabs found, tabs: ${tabs.map(t => `${t.id}=${t.url}`).join(', ')}`);
|
|
265
|
+
if (debuggableTab?.id) return debuggableTab.id;
|
|
271
266
|
|
|
272
|
-
// No debuggable tab
|
|
273
|
-
//
|
|
274
|
-
// Reuse the first existing tab by navigating it to about:blank (avoids
|
|
275
|
-
// accumulating orphan tabs if chrome.tabs.create is also intercepted).
|
|
267
|
+
// No debuggable tab — another extension may have hijacked the tab URL.
|
|
268
|
+
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
|
|
276
269
|
const reuseTab = tabs.find(t => t.id);
|
|
277
270
|
if (reuseTab?.id) {
|
|
278
271
|
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
|
|
279
|
-
// Wait for the navigation to take effect
|
|
280
272
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
281
|
-
// Verify the URL is actually debuggable (New Tab Override may have intercepted)
|
|
282
273
|
try {
|
|
283
274
|
const updated = await chrome.tabs.get(reuseTab.id);
|
|
284
275
|
if (isDebuggableUrl(updated.url)) return reuseTab.id;
|
|
285
|
-
|
|
286
|
-
console.warn(`[opencli] about:blank was intercepted (${updated.url}), trying data: URI`);
|
|
287
|
-
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
|
|
288
|
-
await new Promise(resolve => setTimeout(resolve, 300));
|
|
289
|
-
const updated2 = await chrome.tabs.get(reuseTab.id);
|
|
290
|
-
if (isDebuggableUrl(updated2.url)) return reuseTab.id;
|
|
291
|
-
// data: URI also intercepted — create a brand new tab
|
|
292
|
-
console.warn(`[opencli] data: URI also intercepted, creating fresh tab`);
|
|
276
|
+
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
|
293
277
|
} catch {
|
|
294
278
|
// Tab was closed during navigation
|
|
295
279
|
}
|
|
296
280
|
}
|
|
297
281
|
|
|
298
|
-
//
|
|
282
|
+
// Fallback: create a new tab
|
|
299
283
|
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
|
|
300
284
|
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
|
301
285
|
return newTab.id;
|
package/extension/src/cdp.ts
CHANGED
|
@@ -47,15 +47,18 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
47
47
|
await chrome.debugger.attach({ tabId }, '1.3');
|
|
48
48
|
} catch (e: unknown) {
|
|
49
49
|
const msg = e instanceof Error ? e.message : String(e);
|
|
50
|
+
const hint = msg.includes('chrome-extension://')
|
|
51
|
+
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
|
52
|
+
: '';
|
|
50
53
|
if (msg.includes('Another debugger is already attached')) {
|
|
51
54
|
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
52
55
|
try {
|
|
53
56
|
await chrome.debugger.attach({ tabId }, '1.3');
|
|
54
57
|
} catch {
|
|
55
|
-
throw new Error(`attach failed: ${msg}`);
|
|
58
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
56
59
|
}
|
|
57
60
|
} else {
|
|
58
|
-
throw new Error(`attach failed: ${msg}`);
|
|
61
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
attached.add(tabId);
|
package/package.json
CHANGED
package/src/browser/cdp.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
scrollJs,
|
|
21
21
|
autoScrollJs,
|
|
22
22
|
networkRequestsJs,
|
|
23
|
+
waitForDomStableJs,
|
|
23
24
|
} from './dom-helpers.js';
|
|
24
25
|
|
|
25
26
|
export interface CDPTarget {
|
|
@@ -177,10 +178,11 @@ class CDPPage implements IPage {
|
|
|
177
178
|
.catch(() => {}); // Don't fail if event times out
|
|
178
179
|
await this.bridge.send('Page.navigate', { url });
|
|
179
180
|
await loadPromise;
|
|
180
|
-
//
|
|
181
|
+
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
182
|
+
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
181
183
|
if (options?.waitUntil !== 'none') {
|
|
182
|
-
const
|
|
183
|
-
await
|
|
184
|
+
const maxMs = options?.settleMs ?? 1000;
|
|
185
|
+
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
@@ -44,7 +44,10 @@ export async function isDaemonRunning(): Promise<boolean> {
|
|
|
44
44
|
try {
|
|
45
45
|
const controller = new AbortController();
|
|
46
46
|
const timer = setTimeout(() => controller.abort(), 2000);
|
|
47
|
-
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
47
|
+
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
48
|
+
headers: { 'X-OpenCLI': '1' },
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
48
51
|
clearTimeout(timer);
|
|
49
52
|
return res.ok;
|
|
50
53
|
} catch {
|
|
@@ -59,7 +62,10 @@ export async function isExtensionConnected(): Promise<boolean> {
|
|
|
59
62
|
try {
|
|
60
63
|
const controller = new AbortController();
|
|
61
64
|
const timer = setTimeout(() => controller.abort(), 2000);
|
|
62
|
-
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
65
|
+
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
66
|
+
headers: { 'X-OpenCLI': '1' },
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
63
69
|
clearTimeout(timer);
|
|
64
70
|
if (!res.ok) return false;
|
|
65
71
|
const data = await res.json() as { extensionConnected?: boolean };
|
|
@@ -90,7 +96,7 @@ export async function sendCommand(
|
|
|
90
96
|
|
|
91
97
|
const res = await fetch(`${DAEMON_URL}/command`, {
|
|
92
98
|
method: 'POST',
|
|
93
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
|
|
94
100
|
body: JSON.stringify(command),
|
|
95
101
|
signal: controller.signal,
|
|
96
102
|
});
|
package/src/browser/discover.ts
CHANGED
|
@@ -18,7 +18,9 @@ export async function checkDaemonStatus(): Promise<{
|
|
|
18
18
|
}> {
|
|
19
19
|
try {
|
|
20
20
|
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
|
21
|
-
const res = await fetch(`http://127.0.0.1:${port}/status
|
|
21
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
22
|
+
headers: { 'X-OpenCLI': '1' },
|
|
23
|
+
});
|
|
22
24
|
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
|
|
23
25
|
return { running: true, extensionConnected: data.extensionConnected };
|
|
24
26
|
} catch {
|
|
@@ -145,3 +145,37 @@ export function networkRequestsJs(includeStatic: boolean): string {
|
|
|
145
145
|
})()
|
|
146
146
|
`;
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`),
|
|
151
|
+
* with a hard cap at `maxMs`. Uses MutationObserver in the browser.
|
|
152
|
+
*
|
|
153
|
+
* Returns as soon as the page stops changing, avoiding unnecessary fixed waits.
|
|
154
|
+
* If document.body is not available, falls back to a fixed sleep of maxMs.
|
|
155
|
+
*/
|
|
156
|
+
export function waitForDomStableJs(maxMs: number, quietMs: number): string {
|
|
157
|
+
return `
|
|
158
|
+
new Promise(resolve => {
|
|
159
|
+
if (!document.body) {
|
|
160
|
+
setTimeout(() => resolve('nobody'), ${maxMs});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let timer = null;
|
|
164
|
+
let cap = null;
|
|
165
|
+
const done = (reason) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
clearTimeout(cap);
|
|
168
|
+
obs.disconnect();
|
|
169
|
+
resolve(reason);
|
|
170
|
+
};
|
|
171
|
+
const resetQuiet = () => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
timer = setTimeout(() => done('quiet'), ${quietMs});
|
|
174
|
+
};
|
|
175
|
+
const obs = new MutationObserver(resetQuiet);
|
|
176
|
+
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
177
|
+
resetQuiet();
|
|
178
|
+
cap = setTimeout(() => done('capped'), ${maxMs});
|
|
179
|
+
})
|
|
180
|
+
`;
|
|
181
|
+
}
|
package/src/browser/page.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
scrollJs,
|
|
24
24
|
autoScrollJs,
|
|
25
25
|
networkRequestsJs,
|
|
26
|
+
waitForDomStableJs,
|
|
26
27
|
} from './dom-helpers.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -53,11 +54,15 @@ export class Page implements IPage {
|
|
|
53
54
|
if (result?.tabId) {
|
|
54
55
|
this._tabId = result.tabId;
|
|
55
56
|
}
|
|
56
|
-
//
|
|
57
|
-
//
|
|
57
|
+
// Smart settle: use DOM stability detection instead of fixed sleep.
|
|
58
|
+
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
58
59
|
if (options?.waitUntil !== 'none') {
|
|
59
|
-
const
|
|
60
|
-
await
|
|
60
|
+
const maxMs = options?.settleMs ?? 1000;
|
|
61
|
+
await sendCommand('exec', {
|
|
62
|
+
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
|
63
|
+
...this._workspaceOpt(),
|
|
64
|
+
...this._tabOpt(),
|
|
65
|
+
});
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
package/src/cli.ts
CHANGED
|
@@ -202,12 +202,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
202
202
|
console.log(renderCascadeResult(result));
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
// ── Built-in: doctor /
|
|
205
|
+
// ── Built-in: doctor / completion ──────────────────────────────────────────
|
|
206
206
|
|
|
207
207
|
program
|
|
208
208
|
.command('doctor')
|
|
209
209
|
.description('Diagnose opencli browser bridge connectivity')
|
|
210
|
-
.option('--live', '
|
|
210
|
+
.option('--no-live', 'Skip live browser connectivity test')
|
|
211
211
|
.option('--sessions', 'Show active automation sessions', false)
|
|
212
212
|
.action(async (opts) => {
|
|
213
213
|
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
|
@@ -215,14 +215,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
215
215
|
console.log(renderBrowserDoctorReport(report));
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
program
|
|
219
|
-
.command('setup')
|
|
220
|
-
.description('Interactive setup: verify browser bridge connectivity')
|
|
221
|
-
.action(async () => {
|
|
222
|
-
const { runSetup } = await import('./setup.js');
|
|
223
|
-
await runSetup({ cliVersion: PKG_VERSION });
|
|
224
|
-
});
|
|
225
|
-
|
|
226
218
|
program
|
|
227
219
|
.command('completion')
|
|
228
220
|
.description('Output shell completion script')
|
package/src/daemon.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* CLI → HTTP POST /command → daemon → WebSocket → Extension
|
|
6
6
|
* Extension → WebSocket result → daemon → HTTP response → CLI
|
|
7
7
|
*
|
|
8
|
+
* Security (defense-in-depth against browser-based CSRF):
|
|
9
|
+
* 1. Origin check — reject HTTP/WS from non chrome-extension:// origins
|
|
10
|
+
* 2. Custom header — require X-OpenCLI header (browsers can't send it
|
|
11
|
+
* without CORS preflight, which we deny)
|
|
12
|
+
* 3. No CORS headers — responses never include Access-Control-Allow-Origin
|
|
13
|
+
* 4. Body size limit — 1 MB max to prevent OOM
|
|
14
|
+
* 5. WebSocket verifyClient — reject upgrade before connection is established
|
|
15
|
+
*
|
|
8
16
|
* Lifecycle:
|
|
9
17
|
* - Auto-spawned by opencli on first browser command
|
|
10
18
|
* - Auto-exits after 5 minutes of idle
|
|
@@ -49,25 +57,56 @@ function resetIdleTimer(): void {
|
|
|
49
57
|
|
|
50
58
|
// ─── HTTP Server ─────────────────────────────────────────────────────
|
|
51
59
|
|
|
60
|
+
const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
|
|
61
|
+
|
|
52
62
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
53
63
|
return new Promise((resolve, reject) => {
|
|
54
64
|
const chunks: Buffer[] = [];
|
|
55
|
-
|
|
65
|
+
let size = 0;
|
|
66
|
+
req.on('data', (c: Buffer) => {
|
|
67
|
+
size += c.length;
|
|
68
|
+
if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; }
|
|
69
|
+
chunks.push(c);
|
|
70
|
+
});
|
|
56
71
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
57
72
|
req.on('error', reject);
|
|
58
73
|
});
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
|
|
62
|
-
res.writeHead(status, { 'Content-Type': 'application/json'
|
|
77
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
63
78
|
res.end(JSON.stringify(data));
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
// ─── Security: Origin & custom-header check ──────────────────────
|
|
83
|
+
// Block browser-based CSRF: browsers always send an Origin header on
|
|
84
|
+
// cross-origin requests. Node.js CLI fetch does NOT send Origin, so
|
|
85
|
+
// legitimate CLI requests pass through. Chrome Extension connects via
|
|
86
|
+
// WebSocket (which bypasses this HTTP handler entirely).
|
|
87
|
+
const origin = req.headers['origin'] as string | undefined;
|
|
88
|
+
if (origin && !origin.startsWith('chrome-extension://')) {
|
|
89
|
+
jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// CORS: do NOT send Access-Control-Allow-Origin for normal requests.
|
|
94
|
+
// Only handle preflight so browsers get a definitive "no" answer.
|
|
95
|
+
if (req.method === 'OPTIONS') {
|
|
96
|
+
// No ACAO header → browser will block the actual request.
|
|
97
|
+
res.writeHead(204);
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Require custom header on all HTTP requests. Browsers cannot attach
|
|
103
|
+
// custom headers in "simple" requests, and our preflight returns no
|
|
104
|
+
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
|
|
105
|
+
// blocked even if Origin check is somehow bypassed.
|
|
106
|
+
if (!req.headers['x-opencli']) {
|
|
107
|
+
jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
71
110
|
|
|
72
111
|
const url = req.url ?? '/';
|
|
73
112
|
const pathname = url.split('?')[0];
|
|
@@ -136,7 +175,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
136
175
|
// ─── WebSocket for Extension ─────────────────────────────────────────
|
|
137
176
|
|
|
138
177
|
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
|
|
139
|
-
const wss = new WebSocketServer({
|
|
178
|
+
const wss = new WebSocketServer({
|
|
179
|
+
server: httpServer,
|
|
180
|
+
path: '/ext',
|
|
181
|
+
verifyClient: ({ req }: { req: IncomingMessage }) => {
|
|
182
|
+
// Block browser-originated WebSocket connections. Browsers don't
|
|
183
|
+
// enforce CORS on WebSocket, so a malicious webpage could connect to
|
|
184
|
+
// ws://localhost:19825/ext and impersonate the Extension. Real Chrome
|
|
185
|
+
// Extensions send origin chrome-extension://<id>.
|
|
186
|
+
const origin = req.headers['origin'] as string | undefined;
|
|
187
|
+
return !origin || origin.startsWith('chrome-extension://');
|
|
188
|
+
},
|
|
189
|
+
});
|
|
140
190
|
|
|
141
191
|
wss.on('connection', (ws: WebSocket) => {
|
|
142
192
|
console.error('[daemon] Extension connected');
|
package/src/doctor.test.ts
CHANGED
|
@@ -84,36 +84,28 @@ describe('doctor report rendering', () => {
|
|
|
84
84
|
issues: [],
|
|
85
85
|
}));
|
|
86
86
|
|
|
87
|
-
expect(text).toContain('[SKIP] Connectivity:
|
|
87
|
+
expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
it('reports consistent status when live check auto-starts the daemon', async () => {
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
mockCheckDaemonStatus.mockResolvedValueOnce({ running:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
const report = await runBrowserDoctor({ live:
|
|
100
|
-
|
|
101
|
-
// Status reflects
|
|
102
|
-
expect(report.daemonRunning).toBe(
|
|
91
|
+
// checkDaemonStatus is called twice: once for auto-start check, once for final status.
|
|
92
|
+
// First call: daemon not running (triggers auto-start attempt)
|
|
93
|
+
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
|
|
94
|
+
// Auto-start attempt via BrowserBridge.connect fails
|
|
95
|
+
mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
|
|
96
|
+
// Second call: daemon still not running after failed auto-start
|
|
97
|
+
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
|
|
98
|
+
|
|
99
|
+
const report = await runBrowserDoctor({ live: false });
|
|
100
|
+
|
|
101
|
+
// Status reflects daemon not running
|
|
102
|
+
expect(report.daemonRunning).toBe(false);
|
|
103
103
|
expect(report.extensionConnected).toBe(false);
|
|
104
|
-
// checkDaemonStatus
|
|
105
|
-
expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(
|
|
106
|
-
// Should
|
|
107
|
-
expect(report.issues).not.toContain(
|
|
108
|
-
expect.stringContaining('Daemon is not running'),
|
|
109
|
-
);
|
|
110
|
-
// Should report extension not connected
|
|
111
|
-
expect(report.issues).toEqual(expect.arrayContaining([
|
|
112
|
-
expect.stringContaining('Chrome extension is not connected'),
|
|
113
|
-
]));
|
|
114
|
-
// Should report connectivity failure
|
|
104
|
+
// checkDaemonStatus called twice (initial + final)
|
|
105
|
+
expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(2);
|
|
106
|
+
// Should report daemon not running
|
|
115
107
|
expect(report.issues).toEqual(expect.arrayContaining([
|
|
116
|
-
expect.stringContaining('
|
|
108
|
+
expect.stringContaining('Daemon is not running'),
|
|
117
109
|
]));
|
|
118
110
|
});
|
|
119
111
|
});
|
package/src/doctor.ts
CHANGED
|
@@ -51,7 +51,19 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise<Co
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
54
|
-
//
|
|
54
|
+
// Try to auto-start daemon if it's not running, so we show accurate status.
|
|
55
|
+
let initialStatus = await checkDaemonStatus();
|
|
56
|
+
if (!initialStatus.running) {
|
|
57
|
+
try {
|
|
58
|
+
const mcp = new BrowserBridge();
|
|
59
|
+
await mcp.connect({ timeout: 5 });
|
|
60
|
+
await mcp.close();
|
|
61
|
+
} catch {
|
|
62
|
+
// Auto-start failed; we'll report it below.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Run the live connectivity check — it may also auto-start the daemon as a
|
|
55
67
|
// side-effect, so we read daemon status only *after* all side-effects settle.
|
|
56
68
|
let connectivity: ConnectivityResult | undefined;
|
|
57
69
|
if (opts.live) {
|
|
@@ -109,7 +121,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
109
121
|
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
110
122
|
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
111
123
|
} else {
|
|
112
|
-
lines.push(`${chalk.dim('[SKIP]')} Connectivity:
|
|
124
|
+
lines.push(`${chalk.dim('[SKIP]')} Connectivity: skipped (--no-live)`);
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
if (report.sessions) {
|
package/dist/setup.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* setup.ts — Interactive browser setup for opencli
|
|
3
|
-
*
|
|
4
|
-
* Simplified for daemon-based architecture. No more token management.
|
|
5
|
-
* Just verifies daemon + extension connectivity.
|
|
6
|
-
*/
|
|
7
|
-
export declare function runSetup(opts?: {
|
|
8
|
-
cliVersion?: string;
|
|
9
|
-
token?: string;
|
|
10
|
-
}): Promise<void>;
|
package/dist/setup.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* setup.ts — Interactive browser setup for opencli
|
|
3
|
-
*
|
|
4
|
-
* Simplified for daemon-based architecture. No more token management.
|
|
5
|
-
* Just verifies daemon + extension connectivity.
|
|
6
|
-
*/
|
|
7
|
-
import chalk from 'chalk';
|
|
8
|
-
import { checkDaemonStatus } from './browser/discover.js';
|
|
9
|
-
import { checkConnectivity } from './doctor.js';
|
|
10
|
-
import { BrowserBridge } from './browser/index.js';
|
|
11
|
-
export async function runSetup(opts = {}) {
|
|
12
|
-
console.log();
|
|
13
|
-
console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
|
|
14
|
-
console.log();
|
|
15
|
-
// Step 1: Check daemon
|
|
16
|
-
console.log(chalk.dim(' Checking daemon status...'));
|
|
17
|
-
const status = await checkDaemonStatus();
|
|
18
|
-
if (status.running) {
|
|
19
|
-
console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
console.log(` ${chalk.yellow('!')} Daemon is not running`);
|
|
23
|
-
console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
|
|
24
|
-
console.log(chalk.dim(' Starting daemon now...'));
|
|
25
|
-
// Try to spawn daemon
|
|
26
|
-
const mcp = new BrowserBridge();
|
|
27
|
-
try {
|
|
28
|
-
await mcp.connect({ timeout: 5 });
|
|
29
|
-
await mcp.close();
|
|
30
|
-
console.log(` ${chalk.green('✓')} Daemon started successfully`);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
// Step 2: Check extension
|
|
37
|
-
const statusAfter = await checkDaemonStatus();
|
|
38
|
-
if (statusAfter.extensionConnected) {
|
|
39
|
-
console.log(` ${chalk.green('✓')} Chrome extension connected`);
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
console.log(` ${chalk.red('✗')} Chrome extension not connected`);
|
|
43
|
-
console.log();
|
|
44
|
-
console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
|
|
45
|
-
console.log(chalk.dim(' 1. Download from GitHub Releases'));
|
|
46
|
-
console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
|
|
47
|
-
console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
|
|
48
|
-
console.log(chalk.dim(' 4. Make sure Chrome is running'));
|
|
49
|
-
console.log();
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
// Step 3: Test connectivity
|
|
53
|
-
console.log();
|
|
54
|
-
console.log(chalk.dim(' Testing browser connectivity...'));
|
|
55
|
-
const conn = await checkConnectivity({ timeout: 5 });
|
|
56
|
-
if (conn.ok) {
|
|
57
|
-
console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
|
|
58
|
-
console.log();
|
|
59
|
-
console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
|
|
63
|
-
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
|
|
64
|
-
}
|
|
65
|
-
console.log();
|
|
66
|
-
}
|
package/src/setup.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* setup.ts — Interactive browser setup for opencli
|
|
3
|
-
*
|
|
4
|
-
* Simplified for daemon-based architecture. No more token management.
|
|
5
|
-
* Just verifies daemon + extension connectivity.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import chalk from 'chalk';
|
|
9
|
-
import { checkDaemonStatus } from './browser/discover.js';
|
|
10
|
-
import { checkConnectivity } from './doctor.js';
|
|
11
|
-
import { BrowserBridge } from './browser/index.js';
|
|
12
|
-
|
|
13
|
-
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
|
|
14
|
-
console.log();
|
|
15
|
-
console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
|
|
16
|
-
console.log();
|
|
17
|
-
|
|
18
|
-
// Step 1: Check daemon
|
|
19
|
-
console.log(chalk.dim(' Checking daemon status...'));
|
|
20
|
-
const status = await checkDaemonStatus();
|
|
21
|
-
|
|
22
|
-
if (status.running) {
|
|
23
|
-
console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
|
|
24
|
-
} else {
|
|
25
|
-
console.log(` ${chalk.yellow('!')} Daemon is not running`);
|
|
26
|
-
console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
|
|
27
|
-
console.log(chalk.dim(' Starting daemon now...'));
|
|
28
|
-
|
|
29
|
-
// Try to spawn daemon
|
|
30
|
-
const mcp = new BrowserBridge();
|
|
31
|
-
try {
|
|
32
|
-
await mcp.connect({ timeout: 5 });
|
|
33
|
-
await mcp.close();
|
|
34
|
-
console.log(` ${chalk.green('✓')} Daemon started successfully`);
|
|
35
|
-
} catch {
|
|
36
|
-
console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Step 2: Check extension
|
|
41
|
-
const statusAfter = await checkDaemonStatus();
|
|
42
|
-
if (statusAfter.extensionConnected) {
|
|
43
|
-
console.log(` ${chalk.green('✓')} Chrome extension connected`);
|
|
44
|
-
} else {
|
|
45
|
-
console.log(` ${chalk.red('✗')} Chrome extension not connected`);
|
|
46
|
-
console.log();
|
|
47
|
-
console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
|
|
48
|
-
console.log(chalk.dim(' 1. Download from GitHub Releases'));
|
|
49
|
-
console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
|
|
50
|
-
console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
|
|
51
|
-
console.log(chalk.dim(' 4. Make sure Chrome is running'));
|
|
52
|
-
console.log();
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Step 3: Test connectivity
|
|
57
|
-
console.log();
|
|
58
|
-
console.log(chalk.dim(' Testing browser connectivity...'));
|
|
59
|
-
const conn = await checkConnectivity({ timeout: 5 });
|
|
60
|
-
if (conn.ok) {
|
|
61
|
-
console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
|
|
62
|
-
console.log();
|
|
63
|
-
console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
|
|
64
|
-
} else {
|
|
65
|
-
console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
|
|
66
|
-
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
|
|
67
|
-
}
|
|
68
|
-
console.log();
|
|
69
|
-
}
|