@jackwener/opencli 1.5.1 → 1.5.3

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.
Files changed (145) hide show
  1. package/.github/workflows/ci.yml +6 -7
  2. package/README.md +21 -362
  3. package/dist/browser/cdp.js +20 -1
  4. package/dist/browser/daemon-client.js +3 -2
  5. package/dist/browser/discover.js +11 -7
  6. package/dist/browser/dom-helpers.d.ts +11 -0
  7. package/dist/browser/dom-helpers.js +42 -0
  8. package/dist/browser/dom-helpers.test.d.ts +1 -0
  9. package/dist/browser/dom-helpers.test.js +92 -0
  10. package/dist/browser/index.d.ts +0 -10
  11. package/dist/browser/index.js +0 -11
  12. package/dist/browser/mcp.js +4 -3
  13. package/dist/browser/page.d.ts +2 -0
  14. package/dist/browser/page.js +42 -3
  15. package/dist/browser.test.js +17 -8
  16. package/dist/cli-manifest.json +4 -5
  17. package/dist/clis/36kr/hot.js +1 -1
  18. package/dist/clis/36kr/search.js +1 -1
  19. package/dist/clis/_shared/common.d.ts +8 -0
  20. package/dist/clis/_shared/common.js +10 -0
  21. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  22. package/dist/clis/apple-podcasts/top.js +4 -1
  23. package/dist/clis/bloomberg/news.js +1 -1
  24. package/dist/clis/douban/utils.js +3 -6
  25. package/dist/clis/medium/utils.js +1 -1
  26. package/dist/clis/producthunt/browse.js +1 -1
  27. package/dist/clis/producthunt/hot.js +1 -1
  28. package/dist/clis/sinablog/utils.js +6 -7
  29. package/dist/clis/substack/utils.js +2 -2
  30. package/dist/clis/twitter/block.js +1 -1
  31. package/dist/clis/twitter/bookmark.js +1 -1
  32. package/dist/clis/twitter/delete.js +1 -1
  33. package/dist/clis/twitter/follow.js +1 -1
  34. package/dist/clis/twitter/followers.js +2 -2
  35. package/dist/clis/twitter/following.js +2 -2
  36. package/dist/clis/twitter/hide-reply.js +1 -1
  37. package/dist/clis/twitter/like.js +1 -1
  38. package/dist/clis/twitter/notifications.js +1 -1
  39. package/dist/clis/twitter/profile.js +1 -1
  40. package/dist/clis/twitter/reply-dm.js +1 -1
  41. package/dist/clis/twitter/reply.js +1 -1
  42. package/dist/clis/twitter/search.js +1 -1
  43. package/dist/clis/twitter/unblock.js +1 -1
  44. package/dist/clis/twitter/unbookmark.js +1 -1
  45. package/dist/clis/twitter/unfollow.js +1 -1
  46. package/dist/clis/v2ex/hot.yaml +3 -17
  47. package/dist/clis/weread/shelf.js +132 -9
  48. package/dist/clis/weread/utils.js +5 -1
  49. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  50. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  51. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  52. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  53. package/dist/clis/xiaohongshu/search.test.js +1 -0
  54. package/dist/daemon.js +1 -0
  55. package/dist/doctor.js +7 -3
  56. package/dist/download/index.js +39 -33
  57. package/dist/download/index.test.js +15 -1
  58. package/dist/execution.js +3 -8
  59. package/dist/extension-manifest-regression.test.d.ts +1 -0
  60. package/dist/extension-manifest-regression.test.js +12 -0
  61. package/dist/main.js +2 -0
  62. package/dist/node-network.d.ts +10 -0
  63. package/dist/node-network.js +174 -0
  64. package/dist/node-network.test.d.ts +1 -0
  65. package/dist/node-network.test.js +55 -0
  66. package/dist/pipeline/executor.test.js +1 -0
  67. package/dist/pipeline/steps/download.test.js +1 -0
  68. package/dist/pipeline/steps/intercept.js +4 -5
  69. package/dist/types.d.ts +2 -0
  70. package/dist/utils.d.ts +2 -0
  71. package/dist/utils.js +4 -0
  72. package/dist/weread-private-api-regression.test.js +185 -0
  73. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  74. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  75. package/extension/dist/background.js +4 -2
  76. package/extension/manifest.json +4 -1
  77. package/extension/package-lock.json +2 -2
  78. package/extension/package.json +1 -1
  79. package/extension/src/background.ts +2 -1
  80. package/package.json +2 -1
  81. package/src/browser/cdp.ts +21 -0
  82. package/src/browser/daemon-client.ts +3 -2
  83. package/src/browser/discover.ts +10 -7
  84. package/src/browser/dom-helpers.test.ts +100 -0
  85. package/src/browser/dom-helpers.ts +44 -0
  86. package/src/browser/index.ts +0 -13
  87. package/src/browser/mcp.ts +4 -3
  88. package/src/browser/page.ts +41 -2
  89. package/src/browser.test.ts +19 -9
  90. package/src/clis/36kr/hot.ts +1 -1
  91. package/src/clis/36kr/search.ts +1 -1
  92. package/src/clis/_shared/common.ts +11 -0
  93. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  94. package/src/clis/apple-podcasts/top.ts +4 -1
  95. package/src/clis/bloomberg/news.ts +1 -1
  96. package/src/clis/douban/utils.ts +3 -7
  97. package/src/clis/medium/utils.ts +1 -1
  98. package/src/clis/producthunt/browse.ts +1 -1
  99. package/src/clis/producthunt/hot.ts +1 -1
  100. package/src/clis/sinablog/utils.ts +6 -7
  101. package/src/clis/substack/utils.ts +2 -2
  102. package/src/clis/twitter/block.ts +1 -1
  103. package/src/clis/twitter/bookmark.ts +1 -1
  104. package/src/clis/twitter/delete.ts +1 -1
  105. package/src/clis/twitter/follow.ts +1 -1
  106. package/src/clis/twitter/followers.ts +2 -2
  107. package/src/clis/twitter/following.ts +2 -2
  108. package/src/clis/twitter/hide-reply.ts +1 -1
  109. package/src/clis/twitter/like.ts +1 -1
  110. package/src/clis/twitter/notifications.ts +1 -1
  111. package/src/clis/twitter/profile.ts +1 -1
  112. package/src/clis/twitter/reply-dm.ts +1 -1
  113. package/src/clis/twitter/reply.ts +1 -1
  114. package/src/clis/twitter/search.ts +1 -1
  115. package/src/clis/twitter/unblock.ts +1 -1
  116. package/src/clis/twitter/unbookmark.ts +1 -1
  117. package/src/clis/twitter/unfollow.ts +1 -1
  118. package/src/clis/v2ex/hot.yaml +3 -17
  119. package/src/clis/weread/shelf.ts +169 -9
  120. package/src/clis/weread/utils.ts +6 -1
  121. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  122. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  123. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  124. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  125. package/src/clis/xiaohongshu/search.test.ts +1 -0
  126. package/src/daemon.ts +1 -0
  127. package/src/doctor.ts +9 -5
  128. package/src/download/index.test.ts +19 -1
  129. package/src/download/index.ts +50 -41
  130. package/src/execution.ts +3 -11
  131. package/src/extension-manifest-regression.test.ts +17 -0
  132. package/src/main.ts +3 -0
  133. package/src/node-network.test.ts +93 -0
  134. package/src/node-network.ts +213 -0
  135. package/src/pipeline/executor.test.ts +1 -0
  136. package/src/pipeline/steps/download.test.ts +1 -0
  137. package/src/pipeline/steps/intercept.ts +4 -5
  138. package/src/types.ts +2 -0
  139. package/src/utils.ts +5 -0
  140. package/src/weread-private-api-regression.test.ts +207 -0
  141. package/tests/e2e/browser-public.test.ts +1 -1
  142. package/tests/e2e/output-formats.test.ts +10 -14
  143. package/tests/e2e/plugin-management.test.ts +4 -1
  144. package/tests/e2e/public-commands.test.ts +12 -1
  145. package/vitest.config.ts +1 -15
@@ -1,7 +1,159 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
2
3
  import type { IPage } from '../../types.js';
3
4
  import { fetchPrivateApi } from './utils.js';
4
5
 
6
+ const WEREAD_DOMAIN = 'weread.qq.com';
7
+ const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
8
+
9
+ interface ShelfRow {
10
+ title: string;
11
+ author: string;
12
+ progress: string;
13
+ bookId: string;
14
+ }
15
+
16
+ interface WebShelfRawBook {
17
+ bookId?: string;
18
+ title?: string;
19
+ author?: string;
20
+ }
21
+
22
+ interface WebShelfIndexEntry {
23
+ bookId?: string;
24
+ idx?: number;
25
+ role?: string;
26
+ }
27
+
28
+ interface WebShelfSnapshot {
29
+ cacheFound: boolean;
30
+ rawBooks: WebShelfRawBook[];
31
+ shelfIndexes: WebShelfIndexEntry[];
32
+ }
33
+
34
+ function normalizeShelfLimit(limit: number): number {
35
+ if (!Number.isFinite(limit)) return 0;
36
+ return Math.max(0, Math.trunc(limit));
37
+ }
38
+
39
+ function normalizePrivateApiRows(data: any, limit: number): ShelfRow[] {
40
+ const books: any[] = data?.books ?? [];
41
+ return books.slice(0, limit).map((item: any) => ({
42
+ title: item.bookInfo?.title ?? item.title ?? '',
43
+ author: item.bookInfo?.author ?? item.author ?? '',
44
+ // TODO: readingProgress field name from community docs, verify with real API response
45
+ progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
46
+ bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
47
+ }));
48
+ }
49
+
50
+ function normalizeWebShelfRows(snapshot: WebShelfSnapshot, limit: number): ShelfRow[] {
51
+ if (limit <= 0) return [];
52
+
53
+ const bookById = new Map<string, WebShelfRawBook>();
54
+ for (const book of snapshot.rawBooks) {
55
+ const bookId = String(book?.bookId || '').trim();
56
+ if (!bookId) continue;
57
+ bookById.set(bookId, book);
58
+ }
59
+
60
+ const orderedBookIds = snapshot.shelfIndexes
61
+ .filter((entry) => String(entry?.role || 'book') === 'book')
62
+ .sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
63
+ .map((entry) => String(entry?.bookId || '').trim())
64
+ .filter(Boolean);
65
+
66
+ const fallbackOrder = snapshot.rawBooks
67
+ .map((book) => String(book?.bookId || '').trim())
68
+ .filter(Boolean);
69
+
70
+ const orderedUniqueBookIds = Array.from(new Set([
71
+ ...orderedBookIds,
72
+ ...fallbackOrder,
73
+ ]));
74
+
75
+ return orderedUniqueBookIds
76
+ .map((bookId) => {
77
+ const book = bookById.get(bookId);
78
+ if (!book) return null;
79
+ return {
80
+ title: String(book.title || '').trim(),
81
+ author: String(book.author || '').trim(),
82
+ progress: '-',
83
+ bookId,
84
+ } satisfies ShelfRow;
85
+ })
86
+ .filter((item): item is ShelfRow => Boolean(item && (item.title || item.bookId)))
87
+ .slice(0, limit);
88
+ }
89
+
90
+ /**
91
+ * Read the structured shelf cache from the web shelf page.
92
+ * The page hydrates localStorage with raw book data plus shelf ordering.
93
+ */
94
+ async function loadWebShelfSnapshot(page: IPage): Promise<WebShelfSnapshot> {
95
+ await page.goto(WEREAD_SHELF_URL);
96
+
97
+ const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
98
+ const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
99
+
100
+ if (!currentVid) {
101
+ return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
102
+ }
103
+
104
+ const rawBooksKey = `shelf:rawBooks:${currentVid}`;
105
+ const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
106
+
107
+ const result = await page.evaluate(`
108
+ (() => new Promise((resolve) => {
109
+ const deadline = Date.now() + 5000;
110
+ const rawBooksKey = ${JSON.stringify(rawBooksKey)};
111
+ const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
112
+
113
+ const readJson = (raw) => {
114
+ if (typeof raw !== 'string') return null;
115
+ try {
116
+ return JSON.parse(raw);
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ const poll = () => {
123
+ const rawBooksRaw = localStorage.getItem(rawBooksKey);
124
+ const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
125
+ const rawBooks = readJson(rawBooksRaw);
126
+ const shelfIndexes = readJson(shelfIndexesRaw);
127
+ const cacheFound = Array.isArray(rawBooks);
128
+
129
+ if (cacheFound || Date.now() >= deadline) {
130
+ resolve({
131
+ cacheFound,
132
+ rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
133
+ shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
134
+ });
135
+ return;
136
+ }
137
+
138
+ setTimeout(poll, 100);
139
+ };
140
+
141
+ poll();
142
+ }))
143
+ `);
144
+
145
+ if (!result || typeof result !== 'object') {
146
+ return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
147
+ }
148
+
149
+ const snapshot = result as Partial<WebShelfSnapshot>;
150
+ return {
151
+ cacheFound: snapshot.cacheFound === true,
152
+ rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
153
+ shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
154
+ };
155
+ }
156
+
5
157
  cli({
6
158
  site: 'weread',
7
159
  name: 'shelf',
@@ -13,14 +165,22 @@ cli({
13
165
  ],
14
166
  columns: ['title', 'author', 'progress', 'bookId'],
15
167
  func: async (page: IPage, args) => {
16
- const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
17
- const books: any[] = data?.books ?? [];
18
- return books.slice(0, Number(args.limit)).map((item: any) => ({
19
- title: item.bookInfo?.title ?? item.title ?? '',
20
- author: item.bookInfo?.author ?? item.author ?? '',
21
- // TODO: readingProgress field name from community docs, verify with real API response
22
- progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
23
- bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
24
- }));
168
+ const limit = normalizeShelfLimit(Number(args.limit));
169
+ if (limit <= 0) return [];
170
+
171
+ try {
172
+ const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
173
+ return normalizePrivateApiRows(data, limit);
174
+ } catch (error) {
175
+ if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
176
+ throw error;
177
+ }
178
+
179
+ const snapshot = await loadWebShelfSnapshot(page);
180
+ if (!snapshot.cacheFound) {
181
+ throw error;
182
+ }
183
+ return normalizeWebShelfRows(snapshot, limit);
184
+ }
25
185
  },
26
186
  });
@@ -12,11 +12,16 @@ import type { BrowserCookie, IPage } from '../../types.js';
12
12
  const WEB_API = 'https://weread.qq.com/web';
13
13
  const API = 'https://i.weread.qq.com';
14
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
+ const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
15
16
 
16
17
  function buildCookieHeader(cookies: BrowserCookie[]): string {
17
18
  return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
18
19
  }
19
20
 
21
+ function isAuthErrorResponse(resp: Response, data: any): boolean {
22
+ return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
23
+ }
24
+
20
25
  /**
21
26
  * Fetch a public WeRead web endpoint (Node.js direct fetch).
22
27
  * Used by search and ranking commands (browser: false).
@@ -78,7 +83,7 @@ export async function fetchPrivateApi(page: IPage, path: string, params?: Record
78
83
  throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
79
84
  }
80
85
 
81
- if (resp.status === 401 || data?.errcode === -2010) {
86
+ if (isAuthErrorResponse(resp, data)) {
82
87
  throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
83
88
  }
84
89
  if (!resp.ok) {
@@ -26,6 +26,7 @@ function createPageMock(evaluateResult: any): IPage {
26
26
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
27
27
  getCookies: vi.fn().mockResolvedValue([]),
28
28
  screenshot: vi.fn().mockResolvedValue(''),
29
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
29
30
  };
30
31
  }
31
32
 
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult: any): IPage {
33
33
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
34
34
  getCookies: vi.fn().mockResolvedValue([]),
35
35
  screenshot: vi.fn().mockResolvedValue(''),
36
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
36
37
  };
37
38
  }
38
39
 
@@ -37,6 +37,7 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
37
37
  getInterceptedRequests,
38
38
  getCookies: vi.fn().mockResolvedValue([]),
39
39
  screenshot: vi.fn().mockResolvedValue(''),
40
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
40
41
  };
41
42
  }
42
43
 
@@ -36,6 +36,7 @@ function createPageMock(evaluateResults: any[]): IPage {
36
36
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
37
37
  getCookies: vi.fn().mockResolvedValue([]),
38
38
  screenshot: vi.fn().mockResolvedValue(''),
39
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
39
40
  };
40
41
  }
41
42
 
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults: any[]): IPage {
31
31
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
32
32
  getCookies: vi.fn().mockResolvedValue([]),
33
33
  screenshot: vi.fn().mockResolvedValue(''),
34
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
34
35
  };
35
36
  }
36
37
 
package/src/daemon.ts CHANGED
@@ -198,6 +198,7 @@ const wss = new WebSocketServer({
198
198
  wss.on('connection', (ws: WebSocket) => {
199
199
  console.error('[daemon] Extension connected');
200
200
  extensionWs = ws;
201
+ extensionVersion = null; // cleared until hello message arrives
201
202
 
202
203
  // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
203
204
  let missedPongs = 0;
package/src/doctor.ts CHANGED
@@ -95,11 +95,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
95
95
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
96
96
  }
97
97
 
98
- if (status.extensionVersion && opts.cliVersion && status.extensionVersion !== opts.cliVersion) {
99
- issues.push(
100
- `Extension version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
101
- ' Download the latest extension from: https://github.com/jackwener/opencli/releases',
102
- );
98
+ if (status.extensionVersion && opts.cliVersion) {
99
+ const extMajor = status.extensionVersion.split('.')[0];
100
+ const cliMajor = opts.cliVersion.split('.')[0];
101
+ if (extMajor !== cliMajor) {
102
+ issues.push(
103
+ `Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
104
+ ' Download the latest extension from: https://github.com/jackwener/opencli/releases',
105
+ );
106
+ }
103
107
  }
104
108
 
105
109
  return {
@@ -2,13 +2,14 @@ import * as fs from 'node:fs';
2
2
  import * as http from 'node:http';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { afterEach, describe, expect, it } from 'vitest';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
7
7
 
8
8
  const servers: http.Server[] = [];
9
9
  const tempDirs: string[] = [];
10
10
 
11
11
  afterEach(async () => {
12
+ vi.unstubAllEnvs();
12
13
  await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
13
14
  server.close((err) => (err ? reject(err) : resolve()));
14
15
  })));
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
114
115
  expect(forwardedCookie).toBeUndefined();
115
116
  expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
116
117
  });
118
+
119
+ it('bypasses proxy settings for loopback downloads', async () => {
120
+ vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
121
+
122
+ const baseUrl = await startServer((_req, res) => {
123
+ res.statusCode = 200;
124
+ res.end('ok');
125
+ });
126
+
127
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
128
+ tempDirs.push(tempDir);
129
+ const destPath = path.join(tempDir, 'loopback.txt');
130
+ const result = await httpDownload(`${baseUrl}/ok`, destPath);
131
+
132
+ expect(result).toEqual({ success: true, size: 2 });
133
+ expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
134
+ });
117
135
  });
@@ -5,16 +5,16 @@
5
5
  import { spawn } from 'node:child_process';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
- import * as https from 'node:https';
9
- import * as http from 'node:http';
10
8
  import * as os from 'node:os';
11
- import { Transform } from 'node:stream';
9
+ import { Readable, Transform } from 'node:stream';
10
+ import type { ReadableStream as WebReadableStream } from 'node:stream/web';
12
11
  import { pipeline } from 'node:stream/promises';
13
12
  import { URL } from 'node:url';
14
13
  import type { ProgressBar } from './progress.js';
15
14
  import { isBinaryInstalled } from '../external.js';
16
15
  import type { BrowserCookie } from '../types.js';
17
16
  import { getErrorMessage } from '../errors.js';
17
+ import { fetchWithNodeNetwork } from '../node-network.js';
18
18
 
19
19
  export type { BrowserCookie } from '../types.js';
20
20
 
@@ -89,9 +89,6 @@ export async function httpDownload(
89
89
  const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
90
90
 
91
91
  return new Promise((resolve) => {
92
- const parsedUrl = new URL(url);
93
- const protocol = parsedUrl.protocol === 'https:' ? https : http;
94
-
95
92
  const requestHeaders: Record<string, string> = {
96
93
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
97
94
  ...headers,
@@ -118,37 +115,52 @@ export async function httpDownload(
118
115
  }
119
116
  };
120
117
 
121
- const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
122
- void (async () => {
118
+ void (async () => {
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), timeout);
121
+ try {
122
+ const response = await fetchWithNodeNetwork(url, {
123
+ headers: requestHeaders,
124
+ signal: controller.signal,
125
+ redirect: 'manual',
126
+ });
127
+ clearTimeout(timer);
128
+
123
129
  // Handle redirects before creating any file handles.
124
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
125
- response.resume();
126
- if (redirectCount >= maxRedirects) {
127
- finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
130
+ if (response.status >= 300 && response.status < 400) {
131
+ const location = response.headers.get('location');
132
+ if (location) {
133
+ if (redirectCount >= maxRedirects) {
134
+ finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
135
+ return;
136
+ }
137
+ const redirectUrl = resolveRedirectUrl(url, location);
138
+ const originalHost = new URL(url).hostname;
139
+ const redirectHost = new URL(redirectUrl).hostname;
140
+ const redirectOptions = originalHost === redirectHost
141
+ ? options
142
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
143
+ finish(await httpDownload(
144
+ redirectUrl,
145
+ destPath,
146
+ redirectOptions,
147
+ redirectCount + 1,
148
+ ));
128
149
  return;
129
150
  }
130
- const redirectUrl = resolveRedirectUrl(url, response.headers.location);
131
- const originalHost = new URL(url).hostname;
132
- const redirectHost = new URL(redirectUrl).hostname;
133
- const redirectOptions = originalHost === redirectHost
134
- ? options
135
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
136
- finish(await httpDownload(
137
- redirectUrl,
138
- destPath,
139
- redirectOptions,
140
- redirectCount + 1,
141
- ));
151
+ }
152
+
153
+ if (response.status !== 200) {
154
+ finish({ success: false, size: 0, error: `HTTP ${response.status}` });
142
155
  return;
143
156
  }
144
157
 
145
- if (response.statusCode !== 200) {
146
- response.resume();
147
- finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
158
+ if (!response.body) {
159
+ finish({ success: false, size: 0, error: 'Empty response body' });
148
160
  return;
149
161
  }
150
162
 
151
- const totalSize = parseInt(response.headers['content-length'] || '0', 10);
163
+ const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
152
164
  let received = 0;
153
165
  const progressStream = new Transform({
154
166
  transform(chunk, _encoding, callback) {
@@ -160,26 +172,23 @@ export async function httpDownload(
160
172
 
161
173
  try {
162
174
  await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
163
- await pipeline(response, progressStream, fs.createWriteStream(tempPath));
175
+ await pipeline(
176
+ Readable.fromWeb(response.body as unknown as WebReadableStream),
177
+ progressStream,
178
+ fs.createWriteStream(tempPath),
179
+ );
164
180
  await fs.promises.rename(tempPath, destPath);
165
181
  finish({ success: true, size: received });
166
182
  } catch (err) {
167
183
  await cleanupTempFile();
168
184
  finish({ success: false, size: 0, error: getErrorMessage(err) });
169
185
  }
170
- })();
171
- });
172
-
173
- request.on('error', (err) => {
174
- void (async () => {
186
+ } catch (err) {
187
+ clearTimeout(timer);
175
188
  await cleanupTempFile();
176
- finish({ success: false, size: 0, error: err.message });
177
- })();
178
- });
179
-
180
- request.on('timeout', () => {
181
- request.destroy(new Error('Timeout'));
182
- });
189
+ finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
190
+ }
191
+ })();
183
192
  });
184
193
  }
185
194
 
package/src/execution.ts CHANGED
@@ -19,8 +19,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
19
19
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
20
20
  import { emitHook, type HookContext } from './hooks.js';
21
21
  import { checkDaemonStatus } from './browser/discover.js';
22
- import { PKG_VERSION } from './version.js';
23
- import chalk from 'chalk';
22
+ import { log } from './logger.js';
24
23
 
25
24
  const _loadedModules = new Set<string>();
26
25
 
@@ -186,13 +185,6 @@ export async function executeCommand(
186
185
  ' Then run: opencli doctor',
187
186
  );
188
187
  }
189
- // ── Version mismatch: warn but don't block ──
190
- if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
191
- process.stderr.write(
192
- chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`)
193
- );
194
- }
195
-
196
188
  ensureRequiredEnv(cmd);
197
189
  const BrowserFactory = getBrowserFactory();
198
190
  result = await browserSession(BrowserFactory, async (page) => {
@@ -200,14 +192,14 @@ export async function executeCommand(
200
192
  if (preNavUrl) {
201
193
  const skip = await isAlreadyOnDomain(page, preNavUrl);
202
194
  if (skip) {
203
- if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
195
+ if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
204
196
  } else {
205
197
  try {
206
198
  // goto() already includes smart DOM-settle detection (waitForDomStable).
207
199
  // No additional fixed sleep needed.
208
200
  await page.goto(preNavUrl);
209
201
  } catch (err) {
210
- if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
202
+ if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
211
203
  }
212
204
  }
213
205
  }
@@ -0,0 +1,17 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ describe('extension manifest regression', () => {
6
+ it('keeps host permissions required by chrome.cookies.getAll', async () => {
7
+ const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
8
+ const raw = await fs.readFile(manifestPath, 'utf8');
9
+ const manifest = JSON.parse(raw) as {
10
+ permissions?: string[];
11
+ host_permissions?: string[];
12
+ };
13
+
14
+ expect(manifest.permissions).toContain('cookies');
15
+ expect(manifest.host_permissions).toContain('<all_urls>');
16
+ });
17
+ });
package/src/main.ts CHANGED
@@ -20,8 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
20
20
  import { getCompletions } from './completion.js';
21
21
  import { runCli } from './cli.js';
22
22
  import { emitHook } from './hooks.js';
23
+ import { installNodeNetwork } from './node-network.js';
23
24
  import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
24
25
 
26
+ installNodeNetwork();
27
+
25
28
  const __filename = fileURLToPath(import.meta.url);
26
29
  const __dirname = path.dirname(__filename);
27
30
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { decideProxy, hasProxyEnv } from './node-network.js';
4
+
5
+ describe('node network proxy decisions', () => {
6
+ it('detects common proxy env variables', () => {
7
+ expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
8
+ expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
9
+ expect(hasProxyEnv({})).toBe(false);
10
+ });
11
+
12
+ it('routes external https traffic through https_proxy', () => {
13
+ const decision = decideProxy(
14
+ new URL('https://www.v2ex.com/api/topics/latest.json'),
15
+ { https_proxy: 'http://127.0.0.1:7897' },
16
+ );
17
+
18
+ expect(decision).toEqual({
19
+ mode: 'proxy',
20
+ proxyUrl: 'http://127.0.0.1:7897',
21
+ });
22
+ });
23
+
24
+ it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
25
+ const decision = decideProxy(
26
+ new URL('https://www.v2ex.com/api/topics/latest.json'),
27
+ { HTTP_PROXY: 'http://127.0.0.1:7897' },
28
+ );
29
+
30
+ expect(decision).toEqual({
31
+ mode: 'proxy',
32
+ proxyUrl: 'http://127.0.0.1:7897',
33
+ });
34
+ });
35
+
36
+ it('bypasses proxies for loopback addresses', () => {
37
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
38
+
39
+ expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
40
+ expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
41
+ expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
42
+ });
43
+
44
+ it('honors NO_PROXY domain matches', () => {
45
+ const decision = decideProxy(
46
+ new URL('https://api.example.com/v1/items'),
47
+ {
48
+ https_proxy: 'http://127.0.0.1:7897',
49
+ no_proxy: '.example.com',
50
+ },
51
+ );
52
+
53
+ expect(decision).toEqual({ mode: 'direct' });
54
+ });
55
+
56
+ it('supports wildcard-style NO_PROXY subdomain entries', () => {
57
+ const decision = decideProxy(
58
+ new URL('https://api.example.com/v1/items'),
59
+ {
60
+ https_proxy: 'http://127.0.0.1:7897',
61
+ no_proxy: '*.example.com',
62
+ },
63
+ );
64
+
65
+ expect(decision).toEqual({ mode: 'direct' });
66
+ });
67
+
68
+ it('matches NO_PROXY entries that rely on the default URL port', () => {
69
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
70
+
71
+ expect(decideProxy(
72
+ new URL('https://example.com/'),
73
+ { ...env, NO_PROXY: 'example.com:443' },
74
+ )).toEqual({ mode: 'direct' });
75
+
76
+ expect(decideProxy(
77
+ new URL('http://example.com/health'),
78
+ { ...env, NO_PROXY: 'example.com:80' },
79
+ )).toEqual({ mode: 'direct' });
80
+ });
81
+
82
+ it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
83
+ const decision = decideProxy(
84
+ new URL('http://example.net/data'),
85
+ { ALL_PROXY: 'socks5://127.0.0.1:1080' },
86
+ );
87
+
88
+ expect(decision).toEqual({
89
+ mode: 'proxy',
90
+ proxyUrl: 'socks5://127.0.0.1:1080',
91
+ });
92
+ });
93
+ });