@jackwener/opencli 1.7.3 → 1.7.4

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 (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveTargetJs } from './target-resolver.js';
3
+ /**
4
+ * Tests for the target resolver JS generator.
5
+ *
6
+ * Since resolveTargetJs() produces JS strings for browser evaluate(),
7
+ * we test the generated JS by running it in a simulated DOM-like context
8
+ * and verifying the structure of the output.
9
+ */
10
+ describe('resolveTargetJs', () => {
11
+ it('generates JS that returns structured resolution for numeric ref', () => {
12
+ const js = resolveTargetJs('12');
13
+ expect(js).toContain('data-opencli-ref');
14
+ expect(js).toContain('__opencli_ref_identity');
15
+ expect(js).toContain('"12"');
16
+ });
17
+ it('generates JS that handles CSS selector input', () => {
18
+ const js = resolveTargetJs('#submit-btn');
19
+ expect(js).toContain('querySelectorAll');
20
+ expect(js).toContain('"#submit-btn"');
21
+ });
22
+ it('generates JS with stale_ref detection for numeric refs', () => {
23
+ const js = resolveTargetJs('5');
24
+ expect(js).toContain('stale_ref');
25
+ expect(js).toContain('__opencli_ref_identity');
26
+ });
27
+ it('generates JS with ambiguity detection for CSS selectors', () => {
28
+ const js = resolveTargetJs('.btn');
29
+ expect(js).toContain('ambiguous');
30
+ expect(js).toContain('candidates');
31
+ });
32
+ it('generates JS that rejects unrecognized input', () => {
33
+ const js = resolveTargetJs('???');
34
+ expect(js).toContain('not_found');
35
+ expect(js).toContain('Cannot parse target');
36
+ });
37
+ it('escapes ref value safely', () => {
38
+ const js = resolveTargetJs('"; alert(1); "');
39
+ // JSON.stringify should handle escaping
40
+ expect(js).not.toContain('alert(1); "');
41
+ expect(js).toContain('\\"');
42
+ });
43
+ });
@@ -112,13 +112,15 @@ describe('BrowserBridge state', () => {
112
112
  bridge._state = 'closing';
113
113
  await expect(bridge.connect()).rejects.toThrow('Session is closing');
114
114
  });
115
- it('fails fast when daemon is running but extension is disconnected', async () => {
115
+ it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
116
+ const { PKG_VERSION } = await import('./version.js');
116
117
  vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
117
118
  state: 'no-extension',
118
119
  status: {
119
120
  ok: true,
120
121
  pid: 1,
121
122
  uptime: 0,
123
+ daemonVersion: PKG_VERSION,
122
124
  extensionConnected: false,
123
125
  pending: 0,
124
126
  memoryMB: 0,
@@ -128,6 +130,41 @@ describe('BrowserBridge state', () => {
128
130
  const bridge = new BrowserBridge();
129
131
  await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
130
132
  });
133
+ it('attempts stale daemon replacement when daemonVersion is missing', async () => {
134
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
135
+ state: 'no-extension',
136
+ status: {
137
+ ok: true,
138
+ pid: 1,
139
+ uptime: 0,
140
+ extensionConnected: false,
141
+ pending: 0,
142
+ memoryMB: 0,
143
+ port: 0,
144
+ },
145
+ });
146
+ vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
147
+ const bridge = new BrowserBridge();
148
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
149
+ });
150
+ it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
151
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
152
+ state: 'no-extension',
153
+ status: {
154
+ ok: true,
155
+ pid: 1,
156
+ uptime: 0,
157
+ daemonVersion: '0.0.1',
158
+ extensionConnected: false,
159
+ pending: 0,
160
+ memoryMB: 0,
161
+ port: 0,
162
+ },
163
+ });
164
+ vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
165
+ const bridge = new BrowserBridge();
166
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
167
+ });
131
168
  });
132
169
  describe('stealth anti-detection', () => {
133
170
  it('generates non-empty JS string', () => {
package/dist/src/cli.js CHANGED
@@ -18,8 +18,10 @@ import { PKG_VERSION } from './version.js';
18
18
  import { printCompletionScript } from './completion.js';
19
19
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
20
20
  import { registerAllCommands } from './commanderAdapter.js';
21
- import { EXIT_CODES, getErrorMessage } from './errors.js';
22
- import { daemonStop } from './commands/daemon.js';
21
+ import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
22
+ import { TargetError } from './browser/target-errors.js';
23
+ import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
24
+ import { daemonStatus, daemonStop } from './commands/daemon.js';
23
25
  import { log } from './logger.js';
24
26
  const CLI_FILE = fileURLToPath(import.meta.url);
25
27
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
@@ -249,6 +251,13 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
249
251
  const browser = program
250
252
  .command('browser')
251
253
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
254
+ /** Resolve a ref/CSS target via the unified resolver, throwing TargetError on failure. */
255
+ async function resolveRef(page, ref) {
256
+ const resolution = await page.evaluate(resolveTargetJs(ref));
257
+ if (!resolution.ok) {
258
+ throw new TargetError(resolution);
259
+ }
260
+ }
252
261
  /** Wrap browser actions with error handling and optional --json output */
253
262
  function browserAction(fn) {
254
263
  return async (...args) => {
@@ -257,15 +266,28 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
257
266
  await fn(page, ...args);
258
267
  }
259
268
  catch (err) {
260
- const msg = getErrorMessage(err);
261
- if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
262
- log.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
269
+ if (err instanceof BrowserConnectError) {
270
+ log.error(err.message);
271
+ if (err.hint)
272
+ log.error(`Hint: ${err.hint}`);
263
273
  }
264
- else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
265
- log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
274
+ else if (err instanceof TargetError) {
275
+ log.error(`[${err.code}] ${err.message}`);
276
+ if (err.hint)
277
+ log.error(`Hint: ${err.hint}`);
278
+ if (err.candidates?.length) {
279
+ log.error('Candidates:');
280
+ err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
281
+ }
266
282
  }
267
283
  else {
268
- log.error(msg);
284
+ const msg = getErrorMessage(err);
285
+ if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
286
+ log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
287
+ }
288
+ else {
289
+ log.error(msg);
290
+ }
269
291
  }
270
292
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
271
293
  }
@@ -277,7 +299,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
277
299
  browser.command('open').argument('<url>').description('Open URL in automation window')
278
300
  .action(browserAction(async (page, url) => {
279
301
  // Start session-level capture before navigation (catches initial requests)
280
- const hasSessionCapture = await page.startNetworkCapture?.().then(() => true).catch(() => false);
302
+ const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
281
303
  await page.goto(url);
282
304
  await page.wait(2);
283
305
  // Fallback: inject JS interceptor when session capture is unavailable
@@ -337,12 +359,14 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
337
359
  }));
338
360
  get.command('text').argument('<index>', 'Element index').description('Element text content')
339
361
  .action(browserAction(async (page, index) => {
340
- const text = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.textContent?.trim())(${JSON.stringify(String(index))})`);
362
+ await resolveRef(page, String(index));
363
+ const text = await page.evaluate(getTextResolvedJs());
341
364
  console.log(text ?? '(empty)');
342
365
  }));
343
366
  get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
344
367
  .action(browserAction(async (page, index) => {
345
- const val = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.value)(${JSON.stringify(String(index))})`);
368
+ await resolveRef(page, String(index));
369
+ const val = await page.evaluate(getValueResolvedJs());
346
370
  console.log(val ?? '(empty)');
347
371
  }));
348
372
  get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
@@ -353,7 +377,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
353
377
  }));
354
378
  get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
355
379
  .action(browserAction(async (page, index) => {
356
- const attrs = await page.evaluate(`((idx) => JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="' + idx + '"]')?.attributes].map(a=>[a.name,a.value]))))(${JSON.stringify(String(index))})`);
380
+ await resolveRef(page, String(index));
381
+ const attrs = await page.evaluate(getAttributesResolvedJs());
357
382
  console.log(attrs ?? '{}');
358
383
  }));
359
384
  // ── Interact ──
@@ -369,17 +394,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
369
394
  await page.wait(0.3);
370
395
  await page.typeText(index, text);
371
396
  // Detect autocomplete/combobox fields and wait for dropdown suggestions
372
- const safeIndex = JSON.stringify(String(index));
373
- const isAutocomplete = await page.evaluate(`
374
- (() => {
375
- const el = document.querySelector('[data-opencli-ref="' + ${safeIndex} + '"]');
376
- if (!el) return false;
377
- const role = el.getAttribute('role');
378
- const ac = el.getAttribute('aria-autocomplete');
379
- const list = el.getAttribute('list');
380
- return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
381
- })()
382
- `);
397
+ // __resolved is already set by typeText's resolver call
398
+ const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
383
399
  if (isAutocomplete) {
384
400
  await page.wait(0.4);
385
401
  console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
@@ -391,20 +407,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
391
407
  browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
392
408
  .description('Select dropdown option')
393
409
  .action(browserAction(async (page, index, option) => {
394
- const safeIdx = JSON.stringify(String(index));
395
- const result = await page.evaluate(`
396
- (function() {
397
- var sel = document.querySelector('[data-opencli-ref="' + ${safeIdx} + '"]');
398
- if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
399
- var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
400
- if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
401
- var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
402
- if (setter) setter.call(sel, match.value); else sel.value = match.value;
403
- sel.dispatchEvent(new Event('input', {bubbles:true}));
404
- sel.dispatchEvent(new Event('change', {bubbles:true}));
405
- return { selected: match.text };
406
- })()
407
- `);
410
+ await resolveRef(page, String(index));
411
+ const result = await page.evaluate(selectResolvedJs(option));
408
412
  if (result?.error) {
409
413
  console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
410
414
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
@@ -1003,6 +1007,10 @@ cli({
1003
1007
  });
1004
1008
  // ── Built-in: daemon ──────────────────────────────────────────────────────
1005
1009
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
1010
+ daemonCmd
1011
+ .command('status')
1012
+ .description('Show daemon status')
1013
+ .action(async () => { await daemonStatus(); });
1006
1014
  daemonCmd
1007
1015
  .command('stop')
1008
1016
  .description('Stop the daemon')
@@ -1,5 +1,7 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ export declare function daemonStatus(): Promise<void>;
5
7
  export declare function daemonStop(): Promise<void>;
@@ -1,9 +1,29 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ import { styleText } from 'node:util';
5
7
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
8
+ import { formatDuration } from '../download/progress.js';
6
9
  import { log } from '../logger.js';
10
+ export async function daemonStatus() {
11
+ const status = await fetchDaemonStatus();
12
+ if (!status) {
13
+ console.log(`Daemon: ${styleText('dim', 'not running')}`);
14
+ return;
15
+ }
16
+ const extensionLabel = !status.extensionConnected
17
+ ? styleText('yellow', 'disconnected')
18
+ : status.extensionVersion
19
+ ? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
20
+ : `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
21
+ console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
22
+ console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
23
+ console.log(`Extension: ${extensionLabel}`);
24
+ console.log(`Memory: ${status.memoryMB} MB`);
25
+ console.log(`Port: ${status.port}`);
26
+ }
7
27
  export async function daemonStop() {
8
28
  const status = await fetchDaemonStatus();
9
29
  if (!status) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
3
3
  fetchDaemonStatusMock: vi.fn(),
4
4
  requestDaemonShutdownMock: vi.fn(),
@@ -7,7 +7,70 @@ vi.mock('../browser/daemon-client.js', () => ({
7
7
  fetchDaemonStatus: fetchDaemonStatusMock,
8
8
  requestDaemonShutdown: requestDaemonShutdownMock,
9
9
  }));
10
- import { daemonStop } from './daemon.js';
10
+ import { daemonStatus, daemonStop } from './daemon.js';
11
+ describe('daemonStatus', () => {
12
+ let stdoutSpy;
13
+ beforeEach(() => {
14
+ stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
15
+ fetchDaemonStatusMock.mockReset();
16
+ requestDaemonShutdownMock.mockReset();
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ it('reports "not running" when daemon is unreachable', async () => {
22
+ fetchDaemonStatusMock.mockResolvedValue(null);
23
+ await daemonStatus();
24
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
25
+ });
26
+ it('shows daemon info when running', async () => {
27
+ fetchDaemonStatusMock.mockResolvedValue({
28
+ ok: true,
29
+ pid: 12345,
30
+ uptime: 3661,
31
+ extensionConnected: true,
32
+ extensionVersion: '1.6.8',
33
+ pending: 0,
34
+ memoryMB: 64,
35
+ port: 19825,
36
+ });
37
+ await daemonStatus();
38
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
39
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
40
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
41
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
42
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
43
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
44
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
45
+ });
46
+ it('shows disconnected when extension is not connected', async () => {
47
+ fetchDaemonStatusMock.mockResolvedValue({
48
+ ok: true,
49
+ pid: 99,
50
+ uptime: 120,
51
+ extensionConnected: false,
52
+ pending: 0,
53
+ memoryMB: 32,
54
+ port: 19825,
55
+ });
56
+ await daemonStatus();
57
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
58
+ });
59
+ it('shows version unknown when the connected extension does not report one', async () => {
60
+ fetchDaemonStatusMock.mockResolvedValue({
61
+ ok: true,
62
+ pid: 99,
63
+ uptime: 120,
64
+ extensionConnected: true,
65
+ extensionVersion: undefined,
66
+ pending: 0,
67
+ memoryMB: 32,
68
+ port: 19825,
69
+ });
70
+ await daemonStatus();
71
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('version unknown'));
72
+ });
73
+ });
11
74
  describe('daemonStop', () => {
12
75
  let stderrSpy;
13
76
  beforeEach(() => {
@@ -23,6 +23,7 @@ import { WebSocketServer, WebSocket } from 'ws';
23
23
  import { DEFAULT_DAEMON_PORT } from './constants.js';
24
24
  import { EXIT_CODES } from './errors.js';
25
25
  import { log } from './logger.js';
26
+ import { PKG_VERSION } from './version.js';
26
27
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
27
28
  // ─── State ───────────────────────────────────────────────────────────
28
29
  let extensionWs = null;
@@ -110,6 +111,7 @@ async function handleRequest(req, res) {
110
111
  ok: true,
111
112
  pid: process.pid,
112
113
  uptime,
114
+ daemonVersion: PKG_VERSION,
113
115
  extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
114
116
  extensionVersion,
115
117
  extensionCompatRange,
@@ -18,6 +18,7 @@ export type DoctorReport = {
18
18
  cliVersion?: string;
19
19
  daemonRunning: boolean;
20
20
  daemonFlaky?: boolean;
21
+ daemonVersion?: string;
21
22
  extensionConnected: boolean;
22
23
  extensionFlaky?: boolean;
23
24
  extensionVersion?: string;
@@ -87,6 +87,7 @@ export async function runBrowserDoctor(opts = {}) {
87
87
  const sessions = opts.sessions && health.state === 'ready'
88
88
  ? await listSessions()
89
89
  : undefined;
90
+ const extensionVersion = health.status?.extensionVersion;
90
91
  const issues = [];
91
92
  if (daemonFlaky) {
92
93
  issues.push('Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
@@ -100,16 +101,33 @@ export async function runBrowserDoctor(opts = {}) {
100
101
  'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
101
102
  }
102
103
  else if (daemonRunning && !extensionConnected) {
103
- issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
104
- 'Please install the opencli Browser Bridge extension:\n' +
105
- ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
106
- ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
107
- ' 3. Click "Load unpacked" select the extension folder');
104
+ const daemonVersion = health.status?.daemonVersion;
105
+ const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
106
+ if (isStale) {
107
+ const reason = daemonVersion
108
+ ? `daemon v${daemonVersion} CLI v${opts.cliVersion}`
109
+ : `daemon predates version reporting, CLI is v${opts.cliVersion}`;
110
+ issues.push(`Stale daemon detected: ${reason}.\n` +
111
+ 'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
112
+ ' Quick fix: opencli daemon stop && opencli doctor');
113
+ }
114
+ else {
115
+ issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
116
+ 'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
117
+ 'If the extension is not installed:\n' +
118
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
119
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
120
+ ' 3. Click "Load unpacked" → select the extension folder');
121
+ }
122
+ }
123
+ if (extensionConnected && !extensionVersion) {
124
+ issues.push('Extension is connected but did not report a version.\n' +
125
+ ' This usually means an outdated Browser Bridge extension.\n' +
126
+ ' Reload or reinstall the extension from: https://github.com/jackwener/opencli/releases');
108
127
  }
109
128
  if (connectivity && !connectivity.ok) {
110
129
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
111
130
  }
112
- const extensionVersion = health.status?.extensionVersion;
113
131
  const extensionCompatRange = health.status?.extensionCompatRange;
114
132
  if (extensionVersion && opts.cliVersion && extensionCompatRange) {
115
133
  if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
@@ -137,6 +155,7 @@ export async function runBrowserDoctor(opts = {}) {
137
155
  cliVersion: opts.cliVersion,
138
156
  daemonRunning,
139
157
  daemonFlaky,
158
+ daemonVersion: health.status?.daemonVersion,
140
159
  extensionConnected,
141
160
  extensionFlaky,
142
161
  extensionVersion,
@@ -154,16 +173,20 @@ export function renderBrowserDoctorReport(report) {
154
173
  : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
155
174
  const daemonLabel = report.daemonFlaky
156
175
  ? 'unstable (running during live check, then stopped)'
157
- : report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running';
176
+ : report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
158
177
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
159
178
  // Extension status
160
- const extIcon = report.extensionFlaky
179
+ const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
161
180
  ? styleText('yellow', '[WARN]')
162
181
  : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
163
182
  const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
164
183
  ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
165
184
  : '';
166
- const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint : '';
185
+ const extVersion = !report.extensionConnected
186
+ ? ''
187
+ : report.extensionVersion
188
+ ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
189
+ : styleText('dim', ' (version unknown)');
167
190
  const extLabel = report.extensionFlaky
168
191
  ? 'unstable (connected during live check, then disconnected)'
169
192
  : report.extensionConnected ? 'connected' : 'not connected';
@@ -25,10 +25,11 @@ describe('doctor report rendering', () => {
25
25
  const text = strip(renderBrowserDoctorReport({
26
26
  daemonRunning: true,
27
27
  extensionConnected: true,
28
+ extensionVersion: '1.6.8',
28
29
  issues: [],
29
30
  }));
30
31
  expect(text).toContain('[OK] Daemon: running on port 19825');
31
- expect(text).toContain('[OK] Extension: connected');
32
+ expect(text).toContain('[OK] Extension: connected (v1.6.8)');
32
33
  expect(text).toContain('Everything looks good!');
33
34
  });
34
35
  it('renders MISSING when daemon not running', () => {
@@ -50,6 +51,16 @@ describe('doctor report rendering', () => {
50
51
  expect(text).toContain('[OK] Daemon: running on port 19825');
51
52
  expect(text).toContain('[MISSING] Extension: not connected');
52
53
  });
54
+ it('renders a warning when the extension version is unknown', () => {
55
+ const text = strip(renderBrowserDoctorReport({
56
+ daemonRunning: true,
57
+ extensionConnected: true,
58
+ issues: ['Extension is connected but did not report a version.'],
59
+ }));
60
+ expect(text).toContain('[WARN] Extension: connected (version unknown)');
61
+ expect(text).toContain('Extension is connected but did not report a version.');
62
+ expect(text).not.toContain('Everything looks good!');
63
+ });
53
64
  it('renders connectivity OK when live test succeeds', () => {
54
65
  const text = strip(renderBrowserDoctorReport({
55
66
  daemonRunning: true,
@@ -90,12 +101,8 @@ describe('doctor report rendering', () => {
90
101
  expect(text).toContain('Daemon connectivity is unstable.');
91
102
  });
92
103
  it('reports daemon not running when no-live and auto-start fails', async () => {
93
- // no-live mode: getDaemonHealth called twice (initial check + final status)
94
- // Initial: stopped → triggers auto-start attempt
95
104
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
96
- // Auto-start fails
97
105
  mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
98
- // Final: still stopped
99
106
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
100
107
  const report = await runBrowserDoctor({ live: false });
101
108
  expect(report.daemonRunning).toBe(false);
@@ -106,12 +113,10 @@ describe('doctor report rendering', () => {
106
113
  ]));
107
114
  });
108
115
  it('reports flapping when live check succeeds but final status shows extension disconnected', async () => {
109
- // Live check succeeds
110
116
  mockConnect.mockResolvedValueOnce({
111
117
  evaluate: vi.fn().mockResolvedValue(2),
112
118
  });
113
119
  mockClose.mockResolvedValueOnce(undefined);
114
- // After live check, getDaemonHealth shows no-extension
115
120
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
116
121
  const report = await runBrowserDoctor({ live: true });
117
122
  expect(report.daemonRunning).toBe(true);
@@ -122,12 +127,10 @@ describe('doctor report rendering', () => {
122
127
  ]));
123
128
  });
124
129
  it('reports daemon flapping when live check succeeds but daemon disappears afterward', async () => {
125
- // Live check succeeds
126
130
  mockConnect.mockResolvedValueOnce({
127
131
  evaluate: vi.fn().mockResolvedValue(2),
128
132
  });
129
133
  mockClose.mockResolvedValueOnce(undefined);
130
- // After live check, getDaemonHealth shows stopped
131
134
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
132
135
  const report = await runBrowserDoctor({ live: true });
133
136
  expect(report.daemonRunning).toBe(false);
@@ -151,14 +154,27 @@ describe('doctor report rendering', () => {
151
154
  expect(timeoutSeen).toBe(8);
152
155
  });
153
156
  it('skips auto-start in no-live mode when daemon is already running', async () => {
154
- // no-live mode but daemon already running (no-extension)
155
157
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
156
- // Final status: same
157
158
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
158
159
  const report = await runBrowserDoctor({ live: false });
159
- // Should NOT have tried auto-start since daemon was already running
160
160
  expect(mockConnect).not.toHaveBeenCalled();
161
161
  expect(report.daemonRunning).toBe(true);
162
162
  expect(report.extensionConnected).toBe(false);
163
163
  });
164
+ it('reports an issue when the extension is connected but does not report a version', async () => {
165
+ const status = {
166
+ state: 'ready',
167
+ status: {
168
+ extensionConnected: true,
169
+ extensionVersion: undefined,
170
+ },
171
+ };
172
+ mockGetDaemonHealth
173
+ .mockResolvedValueOnce(status)
174
+ .mockResolvedValueOnce(status);
175
+ const report = await runBrowserDoctor({ live: false });
176
+ expect(report.issues).toEqual(expect.arrayContaining([
177
+ expect.stringContaining('did not report a version'),
178
+ ]));
179
+ });
164
180
  });
@@ -36,8 +36,8 @@
36
36
  homepage: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
37
37
  tags: [dingtalk, collaboration, productivity, ai-agent]
38
38
  install:
39
- mac: "curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh"
40
- linux: "curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh"
39
+ mac: "npm install -g dingtalk-workspace-cli"
40
+ linux: "npm install -g dingtalk-workspace-cli"
41
41
 
42
42
  - name: wecom-cli
43
43
  binary: wecom-cli
@@ -15,9 +15,9 @@ export declare const log: {
15
15
  warn(msg: string): void;
16
16
  /** Error (always shown) */
17
17
  error(msg: string): void;
18
- /** Verbose output (shown when -v flag, OPENCLI_VERBOSE, or DEBUG=opencli is set) */
18
+ /** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
19
19
  verbose(msg: string): void;
20
- /** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
20
+ /** Alias for verbose output. */
21
21
  debug(msg: string): void;
22
22
  /** Step-style debug (for pipeline steps, etc.) */
23
23
  step(stepNum: number, total: number, op: string, preview?: string): void;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { styleText } from 'node:util';
8
8
  function isVerbose() {
9
- return !!process.env.OPENCLI_VERBOSE || !!process.env.DEBUG?.includes('opencli');
9
+ return !!process.env.OPENCLI_VERBOSE;
10
10
  }
11
11
  export const log = {
12
12
  /** Informational message (always shown) */
@@ -29,13 +29,13 @@ export const log = {
29
29
  error(msg) {
30
30
  process.stderr.write(`${styleText('red', '✖')} ${msg}\n`);
31
31
  },
32
- /** Verbose output (shown when -v flag, OPENCLI_VERBOSE, or DEBUG=opencli is set) */
32
+ /** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
33
33
  verbose(msg) {
34
34
  if (isVerbose()) {
35
35
  process.stderr.write(`${styleText('dim', '[verbose]')} ${msg}\n`);
36
36
  }
37
37
  },
38
- /** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
38
+ /** Alias for verbose output. */
39
39
  debug(msg) {
40
40
  this.verbose(msg);
41
41
  },
@@ -17,12 +17,8 @@ function resolveColumns(rows, opts) {
17
17
  export function render(data, opts = {}) {
18
18
  let fmt = opts.fmt ?? 'table';
19
19
  // Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
20
- // Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table
21
20
  if (!opts.fmtExplicit) {
22
- const envFmt = process.env.OUTPUT?.trim().toLowerCase();
23
- if (envFmt)
24
- fmt = envFmt;
25
- else if (fmt === 'table' && !process.stdout.isTTY)
21
+ if (fmt === 'table' && !process.stdout.isTTY)
26
22
  fmt = 'yaml';
27
23
  }
28
24
  if (data === null || data === undefined) {