@jackwener/opencli 0.9.8 → 1.0.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/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/CLI-ELECTRON.md +2 -2
- package/CLI-EXPLORER.md +4 -4
- package/README.md +35 -58
- package/README.zh-CN.md +36 -60
- package/SKILL.md +10 -8
- package/TESTING.md +7 -7
- package/dist/browser/daemon-client.d.ts +37 -0
- package/dist/browser/daemon-client.js +82 -0
- package/dist/browser/discover.d.ts +11 -34
- package/dist/browser/discover.js +15 -205
- package/dist/browser/errors.d.ts +6 -20
- package/dist/browser/errors.js +24 -63
- package/dist/browser/index.d.ts +2 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/neteasemusic/like.d.ts +1 -0
- package/dist/clis/neteasemusic/like.js +25 -0
- package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
- package/dist/clis/neteasemusic/lyrics.js +47 -0
- package/dist/clis/neteasemusic/next.d.ts +1 -0
- package/dist/clis/neteasemusic/next.js +26 -0
- package/dist/clis/neteasemusic/play.d.ts +1 -0
- package/dist/clis/neteasemusic/play.js +26 -0
- package/dist/clis/neteasemusic/playing.d.ts +1 -0
- package/dist/clis/neteasemusic/playing.js +59 -0
- package/dist/clis/neteasemusic/playlist.d.ts +1 -0
- package/dist/clis/neteasemusic/playlist.js +46 -0
- package/dist/clis/neteasemusic/prev.d.ts +1 -0
- package/dist/clis/neteasemusic/prev.js +25 -0
- package/dist/clis/neteasemusic/search.d.ts +1 -0
- package/dist/clis/neteasemusic/search.js +52 -0
- package/dist/clis/neteasemusic/status.d.ts +1 -0
- package/dist/clis/neteasemusic/status.js +16 -0
- package/dist/clis/neteasemusic/volume.d.ts +1 -0
- package/dist/clis/neteasemusic/volume.js +54 -0
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/browser.js +2 -2
- package/dist/pipeline/steps/intercept.js +1 -2
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +31 -0
- package/extension/package.json +16 -0
- package/extension/src/background.ts +370 -0
- package/extension/src/cdp.ts +125 -0
- package/extension/src/protocol.ts +57 -0
- package/extension/store-assets/screenshot-1280x800.png +0 -0
- package/extension/tsconfig.json +15 -0
- package/extension/vite.config.ts +18 -0
- package/package.json +5 -5
- package/src/browser/daemon-client.ts +113 -0
- package/src/browser/discover.ts +18 -232
- package/src/browser/errors.ts +30 -100
- package/src/browser/index.ts +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/neteasemusic/README.md +31 -0
- package/src/clis/neteasemusic/README.zh-CN.md +31 -0
- package/src/clis/neteasemusic/like.ts +28 -0
- package/src/clis/neteasemusic/lyrics.ts +53 -0
- package/src/clis/neteasemusic/next.ts +30 -0
- package/src/clis/neteasemusic/play.ts +30 -0
- package/src/clis/neteasemusic/playing.ts +62 -0
- package/src/clis/neteasemusic/playlist.ts +51 -0
- package/src/clis/neteasemusic/prev.ts +29 -0
- package/src/clis/neteasemusic/search.ts +58 -0
- package/src/clis/neteasemusic/status.ts +18 -0
- package/src/clis/neteasemusic/volume.ts +61 -0
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/browser.ts +2 -2
- package/src/pipeline/steps/intercept.ts +1 -2
- package/src/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { formatDate, fetchWebApi, fetchWithPage } from './utils.js';
|
|
3
|
+
|
|
4
|
+
describe('formatDate', () => {
|
|
5
|
+
it('formats a typical Unix timestamp in UTC+8', () => {
|
|
6
|
+
// 1705276800 = 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Beijing
|
|
7
|
+
expect(formatDate(1705276800)).toBe('2024-01-15');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('handles UTC midnight edge case with UTC+8 offset', () => {
|
|
11
|
+
// 1705190399 = 2024-01-13 23:59:59 UTC = 2024-01-14 07:59:59 Beijing
|
|
12
|
+
expect(formatDate(1705190399)).toBe('2024-01-14');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns dash for zero', () => {
|
|
16
|
+
expect(formatDate(0)).toBe('-');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns dash for negative', () => {
|
|
20
|
+
expect(formatDate(-1)).toBe('-');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns dash for NaN', () => {
|
|
24
|
+
expect(formatDate(NaN)).toBe('-');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns dash for Infinity', () => {
|
|
28
|
+
expect(formatDate(Infinity)).toBe('-');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns dash for undefined', () => {
|
|
32
|
+
expect(formatDate(undefined)).toBe('-');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns dash for null', () => {
|
|
36
|
+
expect(formatDate(null)).toBe('-');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('fetchWebApi', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns parsed JSON for successful response', async () => {
|
|
46
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: () => Promise.resolve({ books: [{ title: 'Test' }] }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const result = await fetchWebApi('/search/global', { keyword: 'test' });
|
|
52
|
+
expect(result).toEqual({ books: [{ title: 'Test' }] });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws CliError on HTTP error', async () => {
|
|
56
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
57
|
+
ok: false,
|
|
58
|
+
status: 403,
|
|
59
|
+
json: () => Promise.resolve({}),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
await expect(fetchWebApi('/search/global')).rejects.toThrow('HTTP 403');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws PARSE_ERROR on non-JSON response', async () => {
|
|
66
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
67
|
+
ok: true,
|
|
68
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
await expect(fetchWebApi('/search/global')).rejects.toThrow('Invalid JSON');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('fetchWithPage', () => {
|
|
76
|
+
it('throws AUTH_REQUIRED on errcode -2010', async () => {
|
|
77
|
+
const mockPage = {
|
|
78
|
+
evaluate: vi.fn().mockResolvedValue({ errcode: -2010, errmsg: '用户不存在' }),
|
|
79
|
+
} as any;
|
|
80
|
+
await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('Not logged in');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws API_ERROR on unknown errcode', async () => {
|
|
84
|
+
const mockPage = {
|
|
85
|
+
evaluate: vi.fn().mockResolvedValue({ errcode: -1, errmsg: 'unknown error' }),
|
|
86
|
+
} as any;
|
|
87
|
+
await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('unknown error');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns data on success (errcode 0 or absent)', async () => {
|
|
91
|
+
const mockPage = {
|
|
92
|
+
evaluate: vi.fn().mockResolvedValue({ title: 'Test Book', errcode: 0 }),
|
|
93
|
+
} as any;
|
|
94
|
+
const result = await fetchWithPage(mockPage, '/book/info');
|
|
95
|
+
expect(result.title).toBe('Test Book');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('throws FETCH_ERROR on HTTP error', async () => {
|
|
99
|
+
const mockPage = {
|
|
100
|
+
evaluate: vi.fn().mockResolvedValue({ _httpError: '403' }),
|
|
101
|
+
} as any;
|
|
102
|
+
await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('HTTP 403');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeRead shared helpers: fetch wrappers and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Two API domains:
|
|
5
|
+
* - WEB_API (weread.qq.com/web/*): public, Node.js fetch
|
|
6
|
+
* - API (i.weread.qq.com/*): private, browser page.evaluate with cookies
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CliError } from '../../errors.js';
|
|
10
|
+
import type { IPage } from '../../types.js';
|
|
11
|
+
|
|
12
|
+
const WEB_API = 'https://weread.qq.com/web';
|
|
13
|
+
const API = 'https://i.weread.qq.com';
|
|
14
|
+
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch a public WeRead web endpoint (Node.js direct fetch).
|
|
18
|
+
* Used by search and ranking commands (browser: false).
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchWebApi(path: string, params?: Record<string, string>): Promise<any> {
|
|
21
|
+
const url = new URL(`${WEB_API}${path}`);
|
|
22
|
+
if (params) {
|
|
23
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
24
|
+
}
|
|
25
|
+
const resp = await fetch(url.toString(), {
|
|
26
|
+
headers: { 'User-Agent': UA },
|
|
27
|
+
});
|
|
28
|
+
if (!resp.ok) {
|
|
29
|
+
throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable');
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return await resp.json();
|
|
33
|
+
} catch {
|
|
34
|
+
throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch a private WeRead API endpoint via browser page.evaluate.
|
|
40
|
+
* Automatically carries cookies for authenticated requests.
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchWithPage(page: IPage, path: string, params?: Record<string, string>): Promise<any> {
|
|
43
|
+
const url = new URL(`${API}${path}`);
|
|
44
|
+
if (params) {
|
|
45
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
46
|
+
}
|
|
47
|
+
const urlStr = url.toString();
|
|
48
|
+
const data = await page.evaluate(`
|
|
49
|
+
async () => {
|
|
50
|
+
const res = await fetch(${JSON.stringify(urlStr)}, { credentials: "include" });
|
|
51
|
+
if (!res.ok) return { _httpError: String(res.status) };
|
|
52
|
+
try { return await res.json(); }
|
|
53
|
+
catch { return { _httpError: 'JSON parse error (status ' + res.status + ')' }; }
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
if (data?._httpError) {
|
|
57
|
+
throw new CliError('FETCH_ERROR', `HTTP ${data._httpError} for ${path}`, 'WeRead API may be temporarily unavailable');
|
|
58
|
+
}
|
|
59
|
+
if (data?.errcode === -2010) {
|
|
60
|
+
throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
|
|
61
|
+
}
|
|
62
|
+
if (data?.errcode != null && data.errcode !== 0) {
|
|
63
|
+
throw new CliError('API_ERROR', data.errmsg ?? `WeRead API error ${data.errcode}`);
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */
|
|
69
|
+
export function formatDate(ts: number | undefined | null): string {
|
|
70
|
+
if (!Number.isFinite(ts) || (ts as number) <= 0) return '-';
|
|
71
|
+
// WeRead timestamps are China-centric; offset to UTC+8 to avoid off-by-one near midnight
|
|
72
|
+
const d = new Date((ts as number) * 1000 + 8 * 3600_000);
|
|
73
|
+
return d.toISOString().slice(0, 10);
|
|
74
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* CLI → HTTP POST /command → daemon → WebSocket → Extension
|
|
6
|
+
* Extension → WebSocket result → daemon → HTTP response → CLI
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* - Auto-spawned by opencli on first browser command
|
|
10
|
+
* - Auto-exits after 5 minutes of idle
|
|
11
|
+
* - Listens on localhost:19825
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
15
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
16
|
+
|
|
17
|
+
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
|
18
|
+
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
19
|
+
|
|
20
|
+
// ─── State ───────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let extensionWs: WebSocket | null = null;
|
|
23
|
+
const pending = new Map<string, {
|
|
24
|
+
resolve: (data: unknown) => void;
|
|
25
|
+
reject: (error: Error) => void;
|
|
26
|
+
timer: ReturnType<typeof setTimeout>;
|
|
27
|
+
}>();
|
|
28
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
|
|
30
|
+
// Extension log ring buffer
|
|
31
|
+
interface LogEntry { level: string; msg: string; ts: number; }
|
|
32
|
+
const LOG_BUFFER_SIZE = 200;
|
|
33
|
+
const logBuffer: LogEntry[] = [];
|
|
34
|
+
|
|
35
|
+
function pushLog(entry: LogEntry): void {
|
|
36
|
+
logBuffer.push(entry);
|
|
37
|
+
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Idle auto-exit ──────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function resetIdleTimer(): void {
|
|
43
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
44
|
+
idleTimer = setTimeout(() => {
|
|
45
|
+
console.error('[daemon] Idle timeout, shutting down');
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}, IDLE_TIMEOUT);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── HTTP Server ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const chunks: Buffer[] = [];
|
|
55
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
56
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
57
|
+
req.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
|
|
62
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
63
|
+
res.end(JSON.stringify(data));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
67
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
68
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
69
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
70
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
71
|
+
|
|
72
|
+
const url = req.url ?? '/';
|
|
73
|
+
const pathname = url.split('?')[0];
|
|
74
|
+
|
|
75
|
+
if (req.method === 'GET' && pathname === '/status') {
|
|
76
|
+
jsonResponse(res, 200, {
|
|
77
|
+
ok: true,
|
|
78
|
+
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
|
|
79
|
+
pending: pending.size,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (req.method === 'GET' && pathname === '/logs') {
|
|
85
|
+
const params = new URL(url, `http://localhost:${PORT}`).searchParams;
|
|
86
|
+
const level = params.get('level');
|
|
87
|
+
const filtered = level
|
|
88
|
+
? logBuffer.filter(e => e.level === level)
|
|
89
|
+
: logBuffer;
|
|
90
|
+
jsonResponse(res, 200, { ok: true, logs: filtered });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (req.method === 'DELETE' && pathname === '/logs') {
|
|
95
|
+
logBuffer.length = 0;
|
|
96
|
+
jsonResponse(res, 200, { ok: true });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (req.method === 'POST' && url === '/command') {
|
|
101
|
+
resetIdleTimer();
|
|
102
|
+
try {
|
|
103
|
+
const body = JSON.parse(await readBody(req));
|
|
104
|
+
if (!body.id) {
|
|
105
|
+
jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
|
|
110
|
+
jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await new Promise<unknown>((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
pending.delete(body.id);
|
|
117
|
+
reject(new Error('Command timeout (120s)'));
|
|
118
|
+
}, 120000);
|
|
119
|
+
pending.set(body.id, { resolve, reject, timer });
|
|
120
|
+
extensionWs!.send(JSON.stringify(body));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
jsonResponse(res, 200, result);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: err instanceof Error ? err.message : 'Invalid request',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
jsonResponse(res, 404, { error: 'Not found' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── WebSocket for Extension ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
|
|
139
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
|
|
140
|
+
|
|
141
|
+
wss.on('connection', (ws) => {
|
|
142
|
+
console.error('[daemon] Extension connected');
|
|
143
|
+
extensionWs = ws;
|
|
144
|
+
|
|
145
|
+
ws.on('message', (data) => {
|
|
146
|
+
try {
|
|
147
|
+
const msg = JSON.parse(data.toString());
|
|
148
|
+
|
|
149
|
+
// Handle log messages from extension
|
|
150
|
+
if (msg.type === 'log') {
|
|
151
|
+
const prefix = msg.level === 'error' ? '❌' : msg.level === 'warn' ? '⚠️' : '📋';
|
|
152
|
+
console.error(`${prefix} [ext] ${msg.msg}`);
|
|
153
|
+
pushLog({ level: msg.level, msg: msg.msg, ts: msg.ts ?? Date.now() });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Handle command results
|
|
158
|
+
const p = pending.get(msg.id);
|
|
159
|
+
if (p) {
|
|
160
|
+
clearTimeout(p.timer);
|
|
161
|
+
pending.delete(msg.id);
|
|
162
|
+
p.resolve(msg);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore malformed messages
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
ws.on('close', () => {
|
|
170
|
+
console.error('[daemon] Extension disconnected');
|
|
171
|
+
if (extensionWs === ws) {
|
|
172
|
+
extensionWs = null;
|
|
173
|
+
// Reject all pending requests since the extension is gone
|
|
174
|
+
for (const [id, p] of pending) {
|
|
175
|
+
clearTimeout(p.timer);
|
|
176
|
+
p.reject(new Error('Extension disconnected'));
|
|
177
|
+
}
|
|
178
|
+
pending.clear();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
ws.on('error', () => {
|
|
183
|
+
if (extensionWs === ws) extensionWs = null;
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ─── Start ───────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
httpServer.listen(PORT, '127.0.0.1', () => {
|
|
190
|
+
console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`);
|
|
191
|
+
resetIdleTimer();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
httpServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
195
|
+
if (err.code === 'EADDRINUSE') {
|
|
196
|
+
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
199
|
+
console.error('[daemon] Server error:', err.message);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Graceful shutdown
|
|
204
|
+
function shutdown(): void {
|
|
205
|
+
// Reject all pending requests so CLI doesn't hang
|
|
206
|
+
for (const [, p] of pending) {
|
|
207
|
+
clearTimeout(p.timer);
|
|
208
|
+
p.reject(new Error('Daemon shutting down'));
|
|
209
|
+
}
|
|
210
|
+
pending.clear();
|
|
211
|
+
if (extensionWs) extensionWs.close();
|
|
212
|
+
httpServer.close();
|
|
213
|
+
process.exit(0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
process.on('SIGTERM', shutdown);
|
|
217
|
+
process.on('SIGINT', shutdown);
|
package/src/doctor.test.ts
CHANGED
|
@@ -1,223 +1,62 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
readTokenFromShellContent,
|
|
4
|
-
renderBrowserDoctorReport,
|
|
5
|
-
upsertShellToken,
|
|
6
|
-
readTomlConfigToken,
|
|
7
|
-
upsertTomlConfigToken,
|
|
8
|
-
upsertJsonConfigToken,
|
|
9
|
-
} from './doctor.js';
|
|
10
|
-
|
|
11
|
-
describe('shell token helpers', () => {
|
|
12
|
-
it('reads token from shell export', () => {
|
|
13
|
-
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('appends token export when missing', () => {
|
|
17
|
-
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
|
|
18
|
-
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('replaces token export when present', () => {
|
|
22
|
-
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
|
|
23
|
-
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
|
|
24
|
-
expect(next).not.toContain('"old"');
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('toml token helpers', () => {
|
|
29
|
-
it('reads token from playwright env section', () => {
|
|
30
|
-
const content = `
|
|
31
|
-
[mcp_servers.playwright.env]
|
|
32
|
-
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
|
|
33
|
-
`;
|
|
34
|
-
expect(readTomlConfigToken(content)).toBe('abc123');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('updates token inside existing env section', () => {
|
|
38
|
-
const content = `
|
|
39
|
-
[mcp_servers.playwright.env]
|
|
40
|
-
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
|
|
41
|
-
`;
|
|
42
|
-
const next = upsertTomlConfigToken(content, 'new');
|
|
43
|
-
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
|
|
44
|
-
expect(next).not.toContain('"old"');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('creates env section when missing', () => {
|
|
48
|
-
const content = `
|
|
49
|
-
[mcp_servers.playwright]
|
|
50
|
-
type = "stdio"
|
|
51
|
-
`;
|
|
52
|
-
const next = upsertTomlConfigToken(content, 'abc123');
|
|
53
|
-
expect(next).toContain('[mcp_servers.playwright.env]');
|
|
54
|
-
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('json token helpers', () => {
|
|
59
|
-
it('writes token into standard mcpServers config', () => {
|
|
60
|
-
const next = upsertJsonConfigToken(JSON.stringify({
|
|
61
|
-
mcpServers: {
|
|
62
|
-
playwright: {
|
|
63
|
-
command: 'npx',
|
|
64
|
-
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
}), 'abc123');
|
|
68
|
-
const parsed = JSON.parse(next);
|
|
69
|
-
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('writes token into opencode mcp config', () => {
|
|
73
|
-
const next = upsertJsonConfigToken(JSON.stringify({
|
|
74
|
-
$schema: 'https://opencode.ai/config.json',
|
|
75
|
-
mcp: {
|
|
76
|
-
playwright: {
|
|
77
|
-
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
78
|
-
enabled: true,
|
|
79
|
-
type: 'local',
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
}), 'abc123');
|
|
83
|
-
const parsed = JSON.parse(next);
|
|
84
|
-
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
|
|
88
|
-
const next = upsertJsonConfigToken('', 'abc123');
|
|
89
|
-
const parsed = JSON.parse(next);
|
|
90
|
-
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
91
|
-
expect(parsed.mcp).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('creates OpenCode format when filePath contains opencode', () => {
|
|
95
|
-
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
|
|
96
|
-
const parsed = JSON.parse(next);
|
|
97
|
-
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
98
|
-
expect(parsed.mcpServers).toBeUndefined();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('creates standard format when filePath is claude.json', () => {
|
|
102
|
-
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
|
|
103
|
-
const parsed = JSON.parse(next);
|
|
104
|
-
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('fish shell support', () => {
|
|
109
|
-
it('generates fish set -gx syntax for fish config path', () => {
|
|
110
|
-
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
|
|
111
|
-
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
112
|
-
expect(next).not.toContain('export');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('replaces existing fish set line', () => {
|
|
116
|
-
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
|
|
117
|
-
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
|
|
118
|
-
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
|
|
119
|
-
expect(next).not.toContain('"old"');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('appends fish syntax to existing fish config', () => {
|
|
123
|
-
const content = 'set -gx PATH /usr/bin\n';
|
|
124
|
-
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
|
|
125
|
-
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
|
|
126
|
-
expect(next).toContain('set -gx PATH /usr/bin');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('uses export syntax for zshrc even with filePath', () => {
|
|
130
|
-
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
|
|
131
|
-
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
|
132
|
-
expect(next).not.toContain('set -gx');
|
|
133
|
-
});
|
|
134
|
-
});
|
|
2
|
+
import { renderBrowserDoctorReport } from './doctor.js';
|
|
135
3
|
|
|
136
4
|
describe('doctor report rendering', () => {
|
|
137
5
|
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
138
6
|
|
|
139
|
-
it('renders OK-style report when
|
|
7
|
+
it('renders OK-style report when daemon and extension connected', () => {
|
|
140
8
|
const text = strip(renderBrowserDoctorReport({
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
extensionToken: 'abc123',
|
|
144
|
-
extensionFingerprint: 'fp1',
|
|
145
|
-
extensionInstalled: true,
|
|
146
|
-
extensionBrowsers: ['Chrome'],
|
|
147
|
-
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
148
|
-
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
149
|
-
recommendedToken: 'abc123',
|
|
150
|
-
recommendedFingerprint: 'fp1',
|
|
151
|
-
warnings: [],
|
|
9
|
+
daemonRunning: true,
|
|
10
|
+
extensionConnected: true,
|
|
152
11
|
issues: [],
|
|
153
12
|
}));
|
|
154
13
|
|
|
155
|
-
expect(text).toContain('[OK]
|
|
156
|
-
expect(text).toContain('[OK]
|
|
157
|
-
expect(text).toContain('
|
|
158
|
-
|
|
14
|
+
expect(text).toContain('[OK] Daemon: running on port 19825');
|
|
15
|
+
expect(text).toContain('[OK] Extension: connected');
|
|
16
|
+
expect(text).toContain('Everything looks good!');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders MISSING when daemon not running', () => {
|
|
20
|
+
const text = strip(renderBrowserDoctorReport({
|
|
21
|
+
daemonRunning: false,
|
|
22
|
+
extensionConnected: false,
|
|
23
|
+
issues: ['Daemon is not running.'],
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
expect(text).toContain('[MISSING] Daemon: not running');
|
|
27
|
+
expect(text).toContain('[MISSING] Extension: not connected');
|
|
28
|
+
expect(text).toContain('Daemon is not running.');
|
|
159
29
|
});
|
|
160
30
|
|
|
161
|
-
it('renders
|
|
31
|
+
it('renders extension not connected when daemon is running', () => {
|
|
162
32
|
const text = strip(renderBrowserDoctorReport({
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
extensionFingerprint: null,
|
|
167
|
-
extensionInstalled: false,
|
|
168
|
-
extensionBrowsers: [],
|
|
169
|
-
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
170
|
-
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
171
|
-
recommendedToken: 'abc123',
|
|
172
|
-
recommendedFingerprint: 'fp1',
|
|
173
|
-
warnings: [],
|
|
174
|
-
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
33
|
+
daemonRunning: true,
|
|
34
|
+
extensionConnected: false,
|
|
35
|
+
issues: ['Daemon is running but the Chrome extension is not connected.'],
|
|
175
36
|
}));
|
|
176
37
|
|
|
177
|
-
expect(text).toContain('[
|
|
178
|
-
expect(text).toContain('[
|
|
179
|
-
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
180
|
-
expect(text).toContain('configured (fp2)');
|
|
181
|
-
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
38
|
+
expect(text).toContain('[OK] Daemon: running on port 19825');
|
|
39
|
+
expect(text).toContain('[MISSING] Extension: not connected');
|
|
182
40
|
});
|
|
183
41
|
|
|
184
42
|
it('renders connectivity OK when live test succeeds', () => {
|
|
185
43
|
const text = strip(renderBrowserDoctorReport({
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
extensionToken: 'abc123',
|
|
189
|
-
extensionFingerprint: 'fp1',
|
|
190
|
-
extensionInstalled: true,
|
|
191
|
-
extensionBrowsers: ['Chrome'],
|
|
192
|
-
shellFiles: [],
|
|
193
|
-
configs: [],
|
|
194
|
-
recommendedToken: 'abc123',
|
|
195
|
-
recommendedFingerprint: 'fp1',
|
|
44
|
+
daemonRunning: true,
|
|
45
|
+
extensionConnected: true,
|
|
196
46
|
connectivity: { ok: true, durationMs: 1234 },
|
|
197
|
-
warnings: [],
|
|
198
47
|
issues: [],
|
|
199
48
|
}));
|
|
200
49
|
|
|
201
|
-
expect(text).toContain('[OK]
|
|
50
|
+
expect(text).toContain('[OK] Connectivity: connected in 1.2s');
|
|
202
51
|
});
|
|
203
52
|
|
|
204
|
-
it('renders connectivity
|
|
53
|
+
it('renders connectivity SKIP when not tested', () => {
|
|
205
54
|
const text = strip(renderBrowserDoctorReport({
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
extensionToken: 'abc123',
|
|
209
|
-
extensionFingerprint: 'fp1',
|
|
210
|
-
extensionInstalled: true,
|
|
211
|
-
extensionBrowsers: ['Chrome'],
|
|
212
|
-
shellFiles: [],
|
|
213
|
-
configs: [],
|
|
214
|
-
recommendedToken: 'abc123',
|
|
215
|
-
recommendedFingerprint: 'fp1',
|
|
216
|
-
warnings: [],
|
|
55
|
+
daemonRunning: true,
|
|
56
|
+
extensionConnected: true,
|
|
217
57
|
issues: [],
|
|
218
58
|
}));
|
|
219
59
|
|
|
220
|
-
expect(text).toContain('[
|
|
60
|
+
expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
|
|
221
61
|
});
|
|
222
62
|
});
|
|
223
|
-
|