@jackwener/opencli 1.6.7 → 1.6.9

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 (122) hide show
  1. package/README.md +5 -1
  2. package/README.zh-CN.md +8 -3
  3. package/dist/clis/1688/assets.d.ts +42 -0
  4. package/dist/clis/1688/assets.js +204 -0
  5. package/dist/clis/1688/assets.test.d.ts +1 -0
  6. package/dist/clis/1688/assets.test.js +39 -0
  7. package/dist/clis/1688/download.d.ts +9 -0
  8. package/dist/clis/1688/download.js +76 -0
  9. package/dist/clis/1688/download.test.d.ts +1 -0
  10. package/dist/clis/1688/download.test.js +31 -0
  11. package/dist/clis/1688/shared.d.ts +10 -0
  12. package/dist/clis/1688/shared.js +43 -0
  13. package/dist/clis/jianyu/search.d.ts +14 -0
  14. package/dist/clis/jianyu/search.js +135 -0
  15. package/dist/clis/jianyu/search.test.d.ts +1 -0
  16. package/dist/clis/jianyu/search.test.js +23 -0
  17. package/dist/clis/linux-do/topic-content.d.ts +35 -0
  18. package/dist/clis/linux-do/topic-content.js +154 -0
  19. package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
  20. package/dist/clis/linux-do/topic-content.test.js +59 -0
  21. package/dist/clis/linux-do/topic.yaml +1 -16
  22. package/dist/clis/quark/ls.d.ts +1 -0
  23. package/dist/clis/quark/ls.js +63 -0
  24. package/dist/clis/quark/mkdir.d.ts +1 -0
  25. package/dist/clis/quark/mkdir.js +36 -0
  26. package/dist/clis/quark/mv.d.ts +1 -0
  27. package/dist/clis/quark/mv.js +53 -0
  28. package/dist/clis/quark/rename.d.ts +1 -0
  29. package/dist/clis/quark/rename.js +26 -0
  30. package/dist/clis/quark/rm.d.ts +1 -0
  31. package/dist/clis/quark/rm.js +24 -0
  32. package/dist/clis/quark/save.d.ts +1 -0
  33. package/dist/clis/quark/save.js +80 -0
  34. package/dist/clis/quark/share-tree.d.ts +1 -0
  35. package/dist/clis/quark/share-tree.js +45 -0
  36. package/dist/clis/quark/utils.d.ts +50 -0
  37. package/dist/clis/quark/utils.js +146 -0
  38. package/dist/clis/quark/utils.test.d.ts +1 -0
  39. package/dist/clis/quark/utils.test.js +58 -0
  40. package/dist/clis/twitter/reply.js +3 -8
  41. package/dist/clis/twitter/reply.test.js +5 -5
  42. package/dist/clis/xiaohongshu/note.js +8 -3
  43. package/dist/clis/xiaohongshu/note.test.js +11 -0
  44. package/dist/clis/xueqiu/groups.yaml +23 -0
  45. package/dist/clis/xueqiu/kline.yaml +65 -0
  46. package/dist/clis/xueqiu/watchlist.yaml +9 -9
  47. package/dist/clis/zhihu/answer.d.ts +1 -0
  48. package/dist/clis/zhihu/answer.js +194 -0
  49. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  50. package/dist/clis/zhihu/answer.test.js +81 -0
  51. package/dist/clis/zhihu/comment.d.ts +1 -0
  52. package/dist/clis/zhihu/comment.js +335 -0
  53. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  54. package/dist/clis/zhihu/comment.test.js +54 -0
  55. package/dist/clis/zhihu/favorite.d.ts +1 -0
  56. package/dist/clis/zhihu/favorite.js +224 -0
  57. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  58. package/dist/clis/zhihu/favorite.test.js +196 -0
  59. package/dist/clis/zhihu/follow.d.ts +1 -0
  60. package/dist/clis/zhihu/follow.js +80 -0
  61. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  62. package/dist/clis/zhihu/follow.test.js +45 -0
  63. package/dist/clis/zhihu/like.d.ts +1 -0
  64. package/dist/clis/zhihu/like.js +91 -0
  65. package/dist/clis/zhihu/like.test.d.ts +1 -0
  66. package/dist/clis/zhihu/like.test.js +64 -0
  67. package/dist/clis/zhihu/target.d.ts +24 -0
  68. package/dist/clis/zhihu/target.js +91 -0
  69. package/dist/clis/zhihu/target.test.d.ts +1 -0
  70. package/dist/clis/zhihu/target.test.js +77 -0
  71. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  72. package/dist/clis/zhihu/write-shared.js +221 -0
  73. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  74. package/dist/clis/zhihu/write-shared.test.js +175 -0
  75. package/dist/src/analysis.d.ts +2 -0
  76. package/dist/src/analysis.js +6 -0
  77. package/dist/src/browser/bridge.d.ts +2 -0
  78. package/dist/src/browser/bridge.js +30 -24
  79. package/dist/src/browser/cdp.js +96 -0
  80. package/dist/src/browser/daemon-client.d.ts +17 -8
  81. package/dist/src/browser/daemon-client.js +12 -13
  82. package/dist/src/browser/daemon-client.test.js +32 -25
  83. package/dist/src/browser/index.d.ts +2 -1
  84. package/dist/src/browser/index.js +1 -1
  85. package/dist/src/browser.test.js +2 -3
  86. package/dist/src/build-manifest.d.ts +3 -1
  87. package/dist/src/build-manifest.js +10 -7
  88. package/dist/src/build-manifest.test.js +8 -4
  89. package/dist/src/cli.d.ts +2 -1
  90. package/dist/src/cli.js +48 -46
  91. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  92. package/dist/src/clis/binance/commands.test.js +54 -0
  93. package/dist/src/commanderAdapter.js +19 -6
  94. package/dist/src/commands/daemon.js +2 -10
  95. package/dist/src/diagnostic.d.ts +28 -2
  96. package/dist/src/diagnostic.js +263 -25
  97. package/dist/src/diagnostic.test.js +220 -1
  98. package/dist/src/discovery.js +7 -17
  99. package/dist/src/doctor.d.ts +2 -0
  100. package/dist/src/doctor.js +59 -31
  101. package/dist/src/doctor.test.js +89 -16
  102. package/dist/src/download/progress.js +7 -2
  103. package/dist/src/execution.js +1 -13
  104. package/dist/src/explore.d.ts +0 -2
  105. package/dist/src/explore.js +61 -38
  106. package/dist/src/extension-manifest-regression.test.js +0 -1
  107. package/dist/src/generate.d.ts +3 -6
  108. package/dist/src/generate.js +4 -8
  109. package/dist/src/package-paths.d.ts +8 -0
  110. package/dist/src/package-paths.js +41 -0
  111. package/dist/src/plugin-scaffold.js +1 -3
  112. package/dist/src/plugin.d.ts +2 -1
  113. package/dist/src/plugin.js +25 -8
  114. package/dist/src/plugin.test.js +16 -1
  115. package/dist/src/record.d.ts +1 -2
  116. package/dist/src/record.js +14 -52
  117. package/dist/src/synthesize.d.ts +0 -2
  118. package/dist/src/synthesize.js +8 -4
  119. package/package.json +3 -3
  120. package/dist/cli-manifest.json +0 -17250
  121. package/dist/src/browser/discover.d.ts +0 -15
  122. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,175 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { CliError } from '@jackwener/opencli/errors';
6
+ import { __test__ } from './write-shared.js';
7
+ class FakeNode {
8
+ attrs;
9
+ textContent;
10
+ hasAvatar;
11
+ constructor(attrs, textContent = null, hasAvatar = false) {
12
+ this.attrs = attrs;
13
+ this.textContent = textContent;
14
+ this.hasAvatar = hasAvatar;
15
+ }
16
+ getAttribute(name) {
17
+ return this.attrs[name] ?? null;
18
+ }
19
+ querySelector(selector) {
20
+ if (this.hasAvatar && selector.includes('img'))
21
+ return {};
22
+ return null;
23
+ }
24
+ }
25
+ class FakeRoot {
26
+ selectors;
27
+ constructor(selectors) {
28
+ this.selectors = selectors;
29
+ }
30
+ querySelectorAll(selector) {
31
+ return this.selectors[selector] ?? [];
32
+ }
33
+ }
34
+ function createPageForDom(documentRoot, state = undefined) {
35
+ return {
36
+ evaluate: vi.fn().mockImplementation(async (js) => {
37
+ const previousDocument = globalThis.document;
38
+ const previousWindow = globalThis.window;
39
+ const previousState = globalThis.__INITIAL_STATE__;
40
+ const windowObject = { __INITIAL_STATE__: state };
41
+ try {
42
+ Object.assign(globalThis, {
43
+ document: documentRoot,
44
+ window: windowObject,
45
+ __INITIAL_STATE__: state,
46
+ });
47
+ return globalThis.eval(js);
48
+ }
49
+ finally {
50
+ Object.assign(globalThis, {
51
+ document: previousDocument,
52
+ window: previousWindow,
53
+ __INITIAL_STATE__: previousState,
54
+ });
55
+ }
56
+ }),
57
+ };
58
+ }
59
+ describe('zhihu write shared helpers', () => {
60
+ it('rejects missing --execute', () => {
61
+ expect(() => __test__.requireExecute({})).toThrowError(CliError);
62
+ });
63
+ it('accepts a non-empty text payload', async () => {
64
+ await expect(__test__.resolvePayload({ text: 'hello' })).resolves.toBe('hello');
65
+ });
66
+ it('rejects whitespace-only payloads', async () => {
67
+ await expect(__test__.resolvePayload({ text: ' ' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
68
+ });
69
+ it('rejects missing file payloads as INVALID_INPUT', async () => {
70
+ await expect(__test__.resolvePayload({ file: join(tmpdir(), 'zhihu-write-shared-missing.txt') })).rejects.toMatchObject({
71
+ code: 'INVALID_INPUT',
72
+ message: expect.stringContaining('File not found'),
73
+ });
74
+ });
75
+ it('rejects invalid UTF-8 file payloads as INVALID_INPUT', async () => {
76
+ const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
77
+ const file = join(dir, 'payload.txt');
78
+ await writeFile(file, Buffer.from([0xc3, 0x28]));
79
+ try {
80
+ await expect(__test__.resolvePayload({ file })).rejects.toMatchObject({
81
+ code: 'INVALID_INPUT',
82
+ message: expect.stringContaining('decoded as UTF-8'),
83
+ });
84
+ }
85
+ finally {
86
+ await rm(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+ it('rejects generic file read failures as INVALID_INPUT', async () => {
90
+ const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
91
+ const file = join(dir, 'payload.txt');
92
+ await writeFile(file, 'hello');
93
+ try {
94
+ await expect(__test__.resolvePayload({ file }, {
95
+ stat: async () => ({ isFile: () => true }),
96
+ readFile: async () => {
97
+ throw new Error('boom');
98
+ },
99
+ decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw),
100
+ })).rejects.toMatchObject({
101
+ code: 'INVALID_INPUT',
102
+ message: expect.stringContaining('could not be read'),
103
+ });
104
+ }
105
+ finally {
106
+ await rm(dir, { recursive: true, force: true });
107
+ }
108
+ });
109
+ it('prefers the state slug before DOM fallback', async () => {
110
+ const documentRoot = new FakeRoot({
111
+ 'header, nav, [role="banner"], [role="navigation"]': [],
112
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/not-used', 'data-testid': 'profile-link' }, null, true)],
113
+ });
114
+ expect(__test__.resolveCurrentUserSlugFromDom({ me: { slug: 'alice' } }, documentRoot)).toBe('alice');
115
+ });
116
+ it('accepts nav avatar links as a conservative fallback', async () => {
117
+ const navRoot = new FakeRoot({
118
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
119
+ });
120
+ const documentRoot = new FakeRoot({
121
+ 'header, nav, [role="banner"], [role="navigation"]': [navRoot],
122
+ 'a[href^="/people/"]': [],
123
+ });
124
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
125
+ });
126
+ it('accepts document-wide fallback only for explicit account/profile signals', async () => {
127
+ const documentRoot = new FakeRoot({
128
+ 'header, nav, [role="banner"], [role="navigation"]': [],
129
+ 'a[href^="/people/"]': [
130
+ new FakeNode({ href: '/people/alice', 'data-testid': 'account-profile-link' }),
131
+ ],
132
+ });
133
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
134
+ });
135
+ it('does not accept a document-wide author avatar link as current-user fallback', async () => {
136
+ const documentRoot = new FakeRoot({
137
+ 'header, nav, [role="banner"], [role="navigation"]': [],
138
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/author-1' }, 'Author', true)],
139
+ });
140
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
141
+ });
142
+ it('does not accept generic document metadata like user or dropdown alone', async () => {
143
+ const documentRoot = new FakeRoot({
144
+ 'header, nav, [role="banner"], [role="navigation"]': [],
145
+ 'a[href^="/people/"]': [
146
+ new FakeNode({ href: '/people/author-1', 'data-testid': 'user-menu-dropdown' }, 'Author'),
147
+ ],
148
+ });
149
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
150
+ });
151
+ it('freezes a stable current-user identity before write', async () => {
152
+ const navRoot = new FakeRoot({
153
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
154
+ });
155
+ const documentRoot = new FakeRoot({
156
+ 'header, nav, [role="banner"], [role="navigation"]': [navRoot],
157
+ 'a[href^="/people/"]': [],
158
+ });
159
+ const page = createPageForDom(documentRoot);
160
+ await expect(__test__.resolveCurrentUserIdentity(page)).resolves.toBe('alice');
161
+ });
162
+ it('rejects when current-user identity cannot be resolved', async () => {
163
+ const documentRoot = new FakeRoot({
164
+ 'header, nav, [role="banner"], [role="navigation"]': [],
165
+ 'a[href^="/people/"]': [],
166
+ });
167
+ const page = createPageForDom(documentRoot);
168
+ await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({
169
+ code: 'ACTION_NOT_AVAILABLE',
170
+ });
171
+ });
172
+ it('rejects reserved buildResultRow extra keys', () => {
173
+ expect(() => __test__.buildResultRow('done', 'question', '123', 'applied', { status: 'oops' })).toThrowError(CliError);
174
+ });
175
+ });
@@ -29,6 +29,8 @@ export declare function inferStrategy(authIndicators: string[]): string;
29
29
  export declare function detectAuthFromHeaders(headers?: Record<string, string>): string[];
30
30
  /** Detect auth indicators from URL and response body (heuristic). */
31
31
  export declare function detectAuthFromContent(url: string, body: unknown): string[];
32
+ /** Check whether a URL looks like tracking/telemetry noise rather than a business API. */
33
+ export declare function isNoiseUrl(url: string): boolean;
32
34
  /** Extract non-volatile query params and classify them. */
33
35
  export declare function classifyQueryParams(url: string): {
34
36
  params: string[];
@@ -148,6 +148,12 @@ export function detectAuthFromContent(url, body) {
148
148
  indicators.push('bearer');
149
149
  return indicators;
150
150
  }
151
+ // ── Noise filtering ─────────────────────────────────────────────────────────
152
+ const NOISE_URL_PATTERN = /\/(track|log|analytics|beacon|pixel|ping|heartbeat|keep.?alive)\b/i;
153
+ /** Check whether a URL looks like tracking/telemetry noise rather than a business API. */
154
+ export function isNoiseUrl(url) {
155
+ return NOISE_URL_PATTERN.test(url);
156
+ }
151
157
  // ── Query param classification ──────────────────────────────────────────────
152
158
  /** Extract non-volatile query params and classify them. */
153
159
  export function classifyQueryParams(url) {
@@ -18,4 +18,6 @@ export declare class BrowserBridge implements IBrowserFactory {
18
18
  }): Promise<IPage>;
19
19
  close(): Promise<void>;
20
20
  private _ensureDaemon;
21
+ /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
22
+ private _pollUntilReady;
21
23
  }
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { Page } from './page.js';
9
- import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
9
+ import { getDaemonHealth } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
+ import { BrowserConnectError } from '../errors.js';
11
12
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
12
13
  /**
13
14
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -52,25 +53,22 @@ export class BrowserBridge {
52
53
  async _ensureDaemon(timeoutSeconds) {
53
54
  const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
54
55
  const timeoutMs = effectiveSeconds * 1000;
55
- // Single status check instead of two separate fetchDaemonStatus() calls
56
- const status = await fetchDaemonStatus();
57
- // Fast path: extension already connected
58
- if (status?.extensionConnected)
56
+ const health = await getDaemonHealth();
57
+ // Fast path: everything ready
58
+ if (health.state === 'ready')
59
59
  return;
60
60
  // Daemon running but no extension — wait for extension with progress
61
- if (status !== null) {
61
+ if (health.state === 'no-extension') {
62
62
  if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
63
63
  process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
64
64
  process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
65
65
  }
66
- const deadline = Date.now() + timeoutMs;
67
- while (Date.now() < deadline) {
68
- await new Promise(resolve => setTimeout(resolve, 200));
69
- if (await isExtensionConnected())
70
- return;
71
- }
72
- throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
73
- 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
66
+ if (await this._pollUntilReady(timeoutMs))
67
+ return;
68
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
69
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
70
+ ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
71
+ ' Then run: opencli doctor', 'extension-not-connected');
74
72
  }
75
73
  // No daemon — spawn one
76
74
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -91,19 +89,27 @@ export class BrowserBridge {
91
89
  env: { ...process.env },
92
90
  });
93
91
  this._daemonProc.unref();
94
- // Wait for daemon + extension with faster polling
92
+ // Wait for daemon + extension
93
+ if (await this._pollUntilReady(timeoutMs))
94
+ return;
95
+ const finalHealth = await getDaemonHealth();
96
+ if (finalHealth.state === 'no-extension') {
97
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
98
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
99
+ ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
100
+ ' Then run: opencli doctor', 'extension-not-connected');
101
+ }
102
+ throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
103
+ }
104
+ /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
105
+ async _pollUntilReady(timeoutMs) {
95
106
  const deadline = Date.now() + timeoutMs;
96
107
  while (Date.now() < deadline) {
97
108
  await new Promise(resolve => setTimeout(resolve, 200));
98
- if (await isExtensionConnected())
99
- return;
100
- }
101
- if ((await fetchDaemonStatus()) !== null) {
102
- throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
103
- 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
109
+ const h = await getDaemonHealth();
110
+ if (h.state === 'ready')
111
+ return true;
104
112
  }
105
- throw new Error('Failed to start opencli daemon. Try running manually:\n' +
106
- ` node ${daemonPath}\n` +
107
- `Make sure port ${DEFAULT_DAEMON_PORT} is available.`);
113
+ return false;
108
114
  }
109
115
  }
@@ -136,6 +136,14 @@ export class CDPBridge {
136
136
  class CDPPage extends BasePage {
137
137
  bridge;
138
138
  _pageEnabled = false;
139
+ // Network capture state (mirrors extension/src/cdp.ts NetworkCaptureEntry shape)
140
+ _networkCapturing = false;
141
+ _networkCapturePattern = '';
142
+ _networkEntries = [];
143
+ _pendingRequests = new Map(); // requestId → index in _networkEntries
144
+ _pendingBodyFetches = new Set(); // track in-flight getResponseBody calls
145
+ _consoleMessages = [];
146
+ _consoleCapturing = false;
139
147
  constructor(bridge) {
140
148
  super();
141
149
  this.bridge = bridge;
@@ -186,6 +194,94 @@ class CDPPage extends BasePage {
186
194
  }
187
195
  return base64;
188
196
  }
197
+ async startNetworkCapture(pattern = '') {
198
+ this._networkCapturePattern = pattern;
199
+ this._networkEntries = [];
200
+ this._pendingRequests.clear();
201
+ this._pendingBodyFetches.clear();
202
+ if (!this._networkCapturing) {
203
+ await this.bridge.send('Network.enable');
204
+ // Step 1: Record request method/url on requestWillBeSent
205
+ this.bridge.on('Network.requestWillBeSent', (params) => {
206
+ const p = params;
207
+ if (!pattern || p.request.url.includes(pattern)) {
208
+ const idx = this._networkEntries.push({
209
+ url: p.request.url,
210
+ method: p.request.method,
211
+ timestamp: p.timestamp,
212
+ }) - 1;
213
+ this._pendingRequests.set(p.requestId, idx);
214
+ }
215
+ });
216
+ // Step 2: Fill in response metadata on responseReceived
217
+ this.bridge.on('Network.responseReceived', (params) => {
218
+ const p = params;
219
+ const idx = this._pendingRequests.get(p.requestId);
220
+ if (idx !== undefined) {
221
+ this._networkEntries[idx].responseStatus = p.response.status;
222
+ this._networkEntries[idx].responseContentType = p.response.mimeType || '';
223
+ }
224
+ });
225
+ // Step 3: Fetch body on loadingFinished (body is only reliably available after this)
226
+ this.bridge.on('Network.loadingFinished', (params) => {
227
+ const p = params;
228
+ const idx = this._pendingRequests.get(p.requestId);
229
+ if (idx !== undefined) {
230
+ const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result) => {
231
+ const r = result;
232
+ if (typeof r?.body === 'string') {
233
+ this._networkEntries[idx].responsePreview = r.base64Encoded
234
+ ? `base64:${r.body.slice(0, 4000)}`
235
+ : r.body.slice(0, 4000);
236
+ }
237
+ }).catch(() => {
238
+ // Body unavailable for some requests (e.g. uploads) — non-fatal
239
+ }).finally(() => {
240
+ this._pendingBodyFetches.delete(bodyFetch);
241
+ });
242
+ this._pendingBodyFetches.add(bodyFetch);
243
+ this._pendingRequests.delete(p.requestId);
244
+ }
245
+ });
246
+ this._networkCapturing = true;
247
+ }
248
+ }
249
+ async readNetworkCapture() {
250
+ // Await all in-flight body fetches so entries have responsePreview populated
251
+ if (this._pendingBodyFetches.size > 0) {
252
+ await Promise.all([...this._pendingBodyFetches]);
253
+ }
254
+ const entries = [...this._networkEntries];
255
+ this._networkEntries = [];
256
+ return entries;
257
+ }
258
+ async consoleMessages(level = 'all') {
259
+ if (!this._consoleCapturing) {
260
+ await this.bridge.send('Runtime.enable');
261
+ this.bridge.on('Runtime.consoleAPICalled', (params) => {
262
+ const p = params;
263
+ const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' ');
264
+ this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp });
265
+ if (this._consoleMessages.length > 500)
266
+ this._consoleMessages.shift();
267
+ });
268
+ // Capture uncaught exceptions as error-level messages
269
+ this.bridge.on('Runtime.exceptionThrown', (params) => {
270
+ const p = params;
271
+ const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception';
272
+ this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp });
273
+ if (this._consoleMessages.length > 500)
274
+ this._consoleMessages.shift();
275
+ });
276
+ this._consoleCapturing = true;
277
+ }
278
+ if (level === 'all')
279
+ return [...this._consoleMessages];
280
+ // 'error' level includes both console.error() and uncaught exceptions
281
+ if (level === 'error')
282
+ return this._consoleMessages.filter(m => m.type === 'error' || m.type === 'warning');
283
+ return this._consoleMessages.filter(m => m.type === level);
284
+ }
189
285
  async tabs() {
190
286
  return [];
191
287
  }
@@ -50,17 +50,26 @@ export interface DaemonStatus {
50
50
  export declare function fetchDaemonStatus(opts?: {
51
51
  timeout?: number;
52
52
  }): Promise<DaemonStatus | null>;
53
+ export type DaemonHealth = {
54
+ state: 'stopped';
55
+ status: null;
56
+ } | {
57
+ state: 'no-extension';
58
+ status: DaemonStatus;
59
+ } | {
60
+ state: 'ready';
61
+ status: DaemonStatus;
62
+ };
63
+ /**
64
+ * Unified daemon health check — single entry point for all status queries.
65
+ * Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
66
+ */
67
+ export declare function getDaemonHealth(opts?: {
68
+ timeout?: number;
69
+ }): Promise<DaemonHealth>;
53
70
  export declare function requestDaemonShutdown(opts?: {
54
71
  timeout?: number;
55
72
  }): Promise<boolean>;
56
- /**
57
- * Check if daemon is running.
58
- */
59
- export declare function isDaemonRunning(): Promise<boolean>;
60
- /**
61
- * Check if daemon is running AND the extension is connected.
62
- */
63
- export declare function isExtensionConnected(): Promise<boolean>;
64
73
  /**
65
74
  * Send a command to the daemon and wait for a result.
66
75
  * Retries up to 4 times: network errors retry at 500ms,
@@ -39,6 +39,18 @@ export async function fetchDaemonStatus(opts) {
39
39
  return null;
40
40
  }
41
41
  }
42
+ /**
43
+ * Unified daemon health check — single entry point for all status queries.
44
+ * Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
45
+ */
46
+ export async function getDaemonHealth(opts) {
47
+ const status = await fetchDaemonStatus(opts);
48
+ if (!status)
49
+ return { state: 'stopped', status: null };
50
+ if (!status.extensionConnected)
51
+ return { state: 'no-extension', status };
52
+ return { state: 'ready', status };
53
+ }
42
54
  export async function requestDaemonShutdown(opts) {
43
55
  try {
44
56
  const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
@@ -48,19 +60,6 @@ export async function requestDaemonShutdown(opts) {
48
60
  return false;
49
61
  }
50
62
  }
51
- /**
52
- * Check if daemon is running.
53
- */
54
- export async function isDaemonRunning() {
55
- return (await fetchDaemonStatus()) !== null;
56
- }
57
- /**
58
- * Check if daemon is running AND the extension is connected.
59
- */
60
- export async function isExtensionConnected() {
61
- const status = await fetchDaemonStatus();
62
- return !!status?.extensionConnected;
63
- }
64
63
  /**
65
64
  * Send a command to the daemon and wait for a result.
66
65
  * Retries up to 4 times: network errors retry at 500ms,
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchDaemonStatus, isDaemonRunning, isExtensionConnected, requestDaemonShutdown, } from './daemon-client.js';
2
+ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
3
3
  describe('daemon-client', () => {
4
4
  beforeEach(() => {
5
5
  vi.stubGlobal('fetch', vi.fn());
@@ -42,36 +42,43 @@ describe('daemon-client', () => {
42
42
  headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
43
43
  }));
44
44
  });
45
- it('isDaemonRunning reflects shared status availability', async () => {
45
+ it('getDaemonHealth returns stopped when daemon is not reachable', async () => {
46
+ vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
47
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null });
48
+ });
49
+ it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => {
50
+ const status = {
51
+ ok: true,
52
+ pid: 123,
53
+ uptime: 10,
54
+ extensionConnected: false,
55
+ pending: 0,
56
+ lastCliRequestTime: Date.now(),
57
+ memoryMB: 16,
58
+ port: 19825,
59
+ };
46
60
  vi.mocked(fetch).mockResolvedValue({
47
61
  ok: true,
48
- json: () => Promise.resolve({
49
- ok: true,
50
- pid: 123,
51
- uptime: 10,
52
- extensionConnected: false,
53
- pending: 0,
54
- lastCliRequestTime: Date.now(),
55
- memoryMB: 16,
56
- port: 19825,
57
- }),
62
+ json: () => Promise.resolve(status),
58
63
  });
59
- await expect(isDaemonRunning()).resolves.toBe(true);
64
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status });
60
65
  });
61
- it('isExtensionConnected reflects shared status payload', async () => {
66
+ it('getDaemonHealth returns ready when daemon and extension are both connected', async () => {
67
+ const status = {
68
+ ok: true,
69
+ pid: 123,
70
+ uptime: 10,
71
+ extensionConnected: true,
72
+ extensionVersion: '1.2.3',
73
+ pending: 0,
74
+ lastCliRequestTime: Date.now(),
75
+ memoryMB: 32,
76
+ port: 19825,
77
+ };
62
78
  vi.mocked(fetch).mockResolvedValue({
63
79
  ok: true,
64
- json: () => Promise.resolve({
65
- ok: true,
66
- pid: 123,
67
- uptime: 10,
68
- extensionConnected: false,
69
- pending: 0,
70
- lastCliRequestTime: Date.now(),
71
- memoryMB: 16,
72
- port: 19825,
73
- }),
80
+ json: () => Promise.resolve(status),
74
81
  });
75
- await expect(isExtensionConnected()).resolves.toBe(false);
82
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
76
83
  });
77
84
  });
@@ -7,7 +7,8 @@
7
7
  export { Page } from './page.js';
8
8
  export { BrowserBridge } from './bridge.js';
9
9
  export { CDPBridge } from './cdp.js';
10
- export { isDaemonRunning } from './daemon-client.js';
10
+ export { getDaemonHealth } from './daemon-client.js';
11
+ export type { DaemonHealth } from './daemon-client.js';
11
12
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
13
  export { generateStealthJs } from './stealth.js';
13
14
  export type { DomSnapshotOptions } from './dom-snapshot.js';
@@ -7,6 +7,6 @@
7
7
  export { Page } from './page.js';
8
8
  export { BrowserBridge } from './bridge.js';
9
9
  export { CDPBridge } from './cdp.js';
10
- export { isDaemonRunning } from './daemon-client.js';
10
+ export { getDaemonHealth } from './daemon-client.js';
11
11
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
12
  export { generateStealthJs } from './stealth.js';
@@ -105,10 +105,9 @@ describe('BrowserBridge state', () => {
105
105
  await expect(bridge.connect()).rejects.toThrow('Session is closing');
106
106
  });
107
107
  it('fails fast when daemon is running but extension is disconnected', async () => {
108
- vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
109
- vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false });
108
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { extensionConnected: false } });
110
109
  const bridge = new BrowserBridge();
111
- await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
110
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
112
111
  });
113
112
  });
114
113
  describe('stealth anti-detection', () => {
@@ -6,7 +6,7 @@
6
6
  * manifest.json for instant cold-start registration (no runtime YAML parsing).
7
7
  *
8
8
  * Usage: npx tsx src/build-manifest.ts
9
- * Output: dist/cli-manifest.json
9
+ * Output: cli-manifest.json at the package root
10
10
  */
11
11
  export interface ManifestEntry {
12
12
  site: string;
@@ -35,6 +35,8 @@ export interface ManifestEntry {
35
35
  type: 'yaml' | 'ts';
36
36
  /** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
37
37
  modulePath?: string;
38
+ /** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */
39
+ sourceFile?: string;
38
40
  /** Pre-navigation control — see CliCommand.navigateBefore */
39
41
  navigateBefore?: boolean | string;
40
42
  }