@jackwener/opencli 1.7.2 → 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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -23,10 +23,12 @@ 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;
29
30
  let extensionVersion = null;
31
+ let extensionCompatRange = null;
30
32
  const pending = new Map();
31
33
  const LOG_BUFFER_SIZE = 200;
32
34
  const logBuffer = [];
@@ -109,8 +111,10 @@ async function handleRequest(req, res) {
109
111
  ok: true,
110
112
  pid: process.pid,
111
113
  uptime,
114
+ daemonVersion: PKG_VERSION,
112
115
  extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
113
116
  extensionVersion,
117
+ extensionCompatRange,
114
118
  pending: pending.size,
115
119
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
116
120
  port: PORT,
@@ -188,6 +192,7 @@ wss.on('connection', (ws) => {
188
192
  log.info('[daemon] Extension connected');
189
193
  extensionWs = ws;
190
194
  extensionVersion = null; // cleared until hello message arrives
195
+ extensionCompatRange = null;
191
196
  // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
192
197
  let missedPongs = 0;
193
198
  const heartbeatInterval = setInterval(() => {
@@ -213,6 +218,7 @@ wss.on('connection', (ws) => {
213
218
  // Handle hello message from extension (version handshake)
214
219
  if (msg.type === 'hello') {
215
220
  extensionVersion = typeof msg.version === 'string' ? msg.version : null;
221
+ extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
216
222
  return;
217
223
  }
218
224
  // Handle log messages from extension
@@ -244,6 +250,7 @@ wss.on('connection', (ws) => {
244
250
  if (extensionWs === ws) {
245
251
  extensionWs = null;
246
252
  extensionVersion = null;
253
+ extensionCompatRange = null;
247
254
  // Reject all pending requests since the extension is gone
248
255
  for (const [id, p] of pending) {
249
256
  clearTimeout(p.timer);
@@ -18,9 +18,11 @@ 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;
25
+ latestExtensionVersion?: string;
24
26
  connectivity?: ConnectivityResult;
25
27
  sessions?: Array<{
26
28
  workspace: string;
@@ -9,7 +9,37 @@ import { BrowserBridge } from './browser/index.js';
9
9
  import { getDaemonHealth, listSessions } from './browser/daemon-client.js';
10
10
  import { getErrorMessage } from './errors.js';
11
11
  import { getRuntimeLabel } from './runtime-detect.js';
12
+ import { getCachedLatestExtensionVersion } from './update-check.js';
12
13
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
14
+ /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
15
+ function parseSemver(v) {
16
+ const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
17
+ if (parts.length < 3 || parts.some(isNaN))
18
+ return null;
19
+ return [parts[0], parts[1], parts[2]];
20
+ }
21
+ /** Returns true if `a` is strictly newer than `b`. */
22
+ function isNewerVersion(a, b) {
23
+ const va = parseSemver(a);
24
+ const vb = parseSemver(b);
25
+ if (!va || !vb)
26
+ return false;
27
+ const cmp = va[0] - vb[0] || va[1] - vb[1] || va[2] - vb[2];
28
+ return cmp > 0;
29
+ }
30
+ /** Check if version satisfies a simple range like ">=1.7.0". */
31
+ function satisfiesRange(version, range) {
32
+ const match = range.match(/^(>=?)\s*(\S+)$/);
33
+ if (!match)
34
+ return true; // Unknown range format — don't block
35
+ const [, op, rangeVer] = match;
36
+ const v = parseSemver(version);
37
+ const r = parseSemver(rangeVer);
38
+ if (!v || !r)
39
+ return true;
40
+ const cmp = v[0] - r[0] || v[1] - r[1] || v[2] - r[2];
41
+ return op === '>=' ? cmp >= 0 : cmp > 0;
42
+ }
13
43
  /**
14
44
  * Test connectivity by attempting a real browser command.
15
45
  */
@@ -57,6 +87,7 @@ export async function runBrowserDoctor(opts = {}) {
57
87
  const sessions = opts.sessions && health.state === 'ready'
58
88
  ? await listSessions()
59
89
  : undefined;
90
+ const extensionVersion = health.status?.extensionVersion;
60
91
  const issues = [];
61
92
  if (daemonFlaky) {
62
93
  issues.push('Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
@@ -70,17 +101,43 @@ export async function runBrowserDoctor(opts = {}) {
70
101
  'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
71
102
  }
72
103
  else if (daemonRunning && !extensionConnected) {
73
- issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
74
- 'Please install the opencli Browser Bridge extension:\n' +
75
- ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
76
- ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
77
- ' 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');
78
127
  }
79
128
  if (connectivity && !connectivity.ok) {
80
129
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
81
130
  }
82
- const extensionVersion = health.status?.extensionVersion;
83
- if (extensionVersion && opts.cliVersion) {
131
+ const extensionCompatRange = health.status?.extensionCompatRange;
132
+ if (extensionVersion && opts.cliVersion && extensionCompatRange) {
133
+ if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
134
+ issues.push(`CLI version incompatible with extension: extension v${extensionVersion} requires CLI ${extensionCompatRange}, but CLI is v${opts.cliVersion}\n` +
135
+ ' Update the CLI: npm install -g @jackwener/opencli\n' +
136
+ ' Or download a compatible extension from: https://github.com/jackwener/opencli/releases');
137
+ }
138
+ }
139
+ else if (extensionVersion && opts.cliVersion) {
140
+ // Fallback for older extensions that don't send compatRange
84
141
  const extMajor = extensionVersion.split('.')[0];
85
142
  const cliMajor = opts.cliVersion.split('.')[0];
86
143
  if (extMajor !== cliMajor) {
@@ -88,13 +145,21 @@ export async function runBrowserDoctor(opts = {}) {
88
145
  ' Download the latest extension from: https://github.com/jackwener/opencli/releases');
89
146
  }
90
147
  }
148
+ // Extension update check (from cached background fetch)
149
+ const latestExtensionVersion = getCachedLatestExtensionVersion();
150
+ if (extensionVersion && latestExtensionVersion && isNewerVersion(latestExtensionVersion, extensionVersion)) {
151
+ issues.push(`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
152
+ ' Download from: https://github.com/jackwener/opencli/releases');
153
+ }
91
154
  return {
92
155
  cliVersion: opts.cliVersion,
93
156
  daemonRunning,
94
157
  daemonFlaky,
158
+ daemonVersion: health.status?.daemonVersion,
95
159
  extensionConnected,
96
160
  extensionFlaky,
97
161
  extensionVersion,
162
+ latestExtensionVersion,
98
163
  connectivity,
99
164
  sessions,
100
165
  issues,
@@ -108,13 +173,20 @@ export function renderBrowserDoctorReport(report) {
108
173
  : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
109
174
  const daemonLabel = report.daemonFlaky
110
175
  ? 'unstable (running during live check, then stopped)'
111
- : 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';
112
177
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
113
178
  // Extension status
114
- const extIcon = report.extensionFlaky
179
+ const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
115
180
  ? styleText('yellow', '[WARN]')
116
181
  : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
117
- const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) : '';
182
+ const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
183
+ ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
184
+ : '';
185
+ const extVersion = !report.extensionConnected
186
+ ? ''
187
+ : report.extensionVersion
188
+ ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
189
+ : styleText('dim', ' (version unknown)');
118
190
  const extLabel = report.extensionFlaky
119
191
  ? 'unstable (connected during live check, then disconnected)'
120
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
  });
@@ -22,7 +22,7 @@ export const builtinApps = {
22
22
  bundleId: 'dev.antigravity.app',
23
23
  displayName: 'Antigravity',
24
24
  },
25
- chatgpt: { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
25
+ 'chatgpt-app': { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
26
26
  };
27
27
  /** Merge builtin + user-defined apps. User entries are additive only. */
28
28
  export function loadApps(userApps) {
@@ -82,6 +82,7 @@ export interface ErrorEnvelope {
82
82
  help?: string;
83
83
  exitCode: number;
84
84
  stack?: string;
85
+ cause?: string;
85
86
  };
86
87
  }
87
88
  /** Extract a human-readable message from an unknown caught value. */
@@ -106,8 +106,19 @@ export class PluginError extends CliError {
106
106
  export function getErrorMessage(error) {
107
107
  return error instanceof Error ? error.message : String(error);
108
108
  }
109
+ /** Serialize an error cause chain into a readable string. */
110
+ function serializeCause(cause) {
111
+ if (cause instanceof Error) {
112
+ const parts = [cause.message];
113
+ if (cause.cause)
114
+ parts.push(` caused by: ${serializeCause(cause.cause)}`);
115
+ return parts.join('\n');
116
+ }
117
+ return String(cause);
118
+ }
109
119
  /** Build an ErrorEnvelope from any caught value. */
110
120
  export function toEnvelope(err) {
121
+ const cause = err instanceof Error && err.cause ? serializeCause(err.cause) : undefined;
111
122
  if (err instanceof CliError) {
112
123
  return {
113
124
  ok: false,
@@ -116,6 +127,7 @@ export function toEnvelope(err) {
116
127
  message: err.message,
117
128
  ...(err.hint ? { help: err.hint } : {}),
118
129
  exitCode: err.exitCode,
130
+ ...(cause ? { cause } : {}),
119
131
  },
120
132
  };
121
133
  }
@@ -126,6 +138,7 @@ export function toEnvelope(err) {
126
138
  code: 'UNKNOWN',
127
139
  message: msg,
128
140
  exitCode: EXIT_CODES.GENERIC_ERROR,
141
+ ...(cause ? { cause } : {}),
129
142
  },
130
143
  };
131
144
  }
@@ -11,16 +11,20 @@
11
11
  */
12
12
  import { getRegistry, fullName } from './registry.js';
13
13
  import { pathToFileURL } from 'node:url';
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
14
16
  import { executePipeline } from './pipeline/index.js';
15
17
  import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
16
18
  import { isDiagnosticEnabled, collectDiagnostic, emitDiagnostic } from './diagnostic.js';
17
19
  import { shouldUseBrowserSession } from './capabilityRouting.js';
18
20
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
19
21
  import { emitHook } from './hooks.js';
20
- import { log } from './logger.js';
21
22
  import { isElectronApp } from './electron-apps.js';
22
23
  import { probeCDP, resolveElectronEndpoint } from './launcher.js';
23
- const _loadedModules = new Set();
24
+ const _loadedModules = new Map();
25
+ /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
26
+ const _moduleMtimes = new Map();
27
+ const _userClisDir = `${os.homedir()}/.opencli/clis/`;
24
28
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
25
29
  const result = { ...kwargs };
26
30
  for (const argDef of cmdArgs) {
@@ -67,15 +71,34 @@ async function runCommand(cmd, page, kwargs, debug) {
67
71
  const internal = cmd;
68
72
  if (internal._lazy && internal._modulePath) {
69
73
  const modulePath = internal._modulePath;
70
- if (!_loadedModules.has(modulePath)) {
74
+ // Hot-reload: if a user adapter's file has changed on disk, invalidate cache
75
+ const isUserAdapter = modulePath.startsWith(_userClisDir);
76
+ if (isUserAdapter && _loadedModules.has(modulePath)) {
71
77
  try {
72
- await import(pathToFileURL(modulePath).href);
73
- _loadedModules.add(modulePath);
78
+ const stat = fs.statSync(modulePath);
79
+ const prevMtime = _moduleMtimes.get(modulePath);
80
+ if (prevMtime !== undefined && stat.mtimeMs !== prevMtime) {
81
+ _loadedModules.delete(modulePath);
82
+ _moduleMtimes.delete(modulePath);
83
+ }
74
84
  }
75
- catch (err) {
85
+ catch { /* file may have been deleted; let import below handle it */ }
86
+ }
87
+ if (!_loadedModules.has(modulePath)) {
88
+ const url = pathToFileURL(modulePath).href;
89
+ const importUrl = _moduleMtimes.has(modulePath) ? `${url}?t=${Date.now()}` : url;
90
+ const loadPromise = import(importUrl).then(() => {
91
+ try {
92
+ _moduleMtimes.set(modulePath, fs.statSync(modulePath).mtimeMs);
93
+ }
94
+ catch { }
95
+ }, (err) => {
96
+ _loadedModules.delete(modulePath);
76
97
  throw new AdapterLoadError(`Failed to load adapter module ${modulePath}: ${getErrorMessage(err)}`, 'Check that the adapter file exists and has no syntax errors.');
77
- }
98
+ });
99
+ _loadedModules.set(modulePath, loadPromise);
78
100
  }
101
+ await _loadedModules.get(modulePath);
79
102
  const updated = getRegistry().get(fullName(cmd));
80
103
  if (updated?.func) {
81
104
  if (!page && updated.browser !== false) {
@@ -159,7 +182,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
159
182
  await page.goto(preNavUrl);
160
183
  }
161
184
  catch (err) {
162
- log.warn(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`);
185
+ throw new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
163
186
  }
164
187
  }
165
188
  try {
@@ -173,13 +196,17 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
173
196
  return result;
174
197
  }
175
198
  catch (err) {
176
- // Collect diagnostic while page is still alive (before browserSession closes it).
199
+ // Collect diagnostic while page is still alive (before closing the window).
177
200
  if (isDiagnosticEnabled()) {
178
201
  const internal = cmd;
179
202
  const ctx = await collectDiagnostic(err, internal, page);
180
203
  emitDiagnostic(ctx);
181
204
  diagnosticEmitted = true;
182
205
  }
206
+ // Close the automation window on failure too — without this, the window
207
+ // lingers until the extension's idle timer fires (unreliable on Windows
208
+ // where MV3 service workers may be suspended before setTimeout triggers).
209
+ await page.closeWindow?.().catch(() => { });
183
210
  throw err;
184
211
  }
185
212
  }, { workspace: `site:${cmd.site}`, cdpEndpoint });
@@ -3,6 +3,8 @@ import { executeCommand, prepareCommandArgs } from './execution.js';
3
3
  import { TimeoutError } from './errors.js';
4
4
  import { cli, Strategy } from './registry.js';
5
5
  import { withTimeoutMs } from './runtime.js';
6
+ import * as runtime from './runtime.js';
7
+ import * as capRouting from './capabilityRouting.js';
6
8
  describe('executeCommand — non-browser timeout', () => {
7
9
  it('applies timeoutSeconds to non-browser commands', async () => {
8
10
  const cmd = cli({
@@ -37,6 +39,27 @@ describe('executeCommand — non-browser timeout', () => {
37
39
  // With timeout guard skipped, the sentinel fires instead.
38
40
  await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
39
41
  });
42
+ it('calls closeWindow on browser command failure', async () => {
43
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
44
+ const mockPage = { closeWindow };
45
+ // Mock shouldUseBrowserSession to return true
46
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
47
+ // Mock browserSession to invoke the callback with our mock page
48
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => {
49
+ return fn(mockPage);
50
+ });
51
+ const cmd = cli({
52
+ site: 'test-execution',
53
+ name: 'browser-close-on-error',
54
+ description: 'test closeWindow on failure',
55
+ browser: true,
56
+ strategy: Strategy.PUBLIC,
57
+ func: async () => { throw new Error('adapter failure'); },
58
+ });
59
+ await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
60
+ expect(closeWindow).toHaveBeenCalledTimes(1);
61
+ vi.restoreAllMocks();
62
+ });
40
63
  it('does not re-run custom validation when args are already prepared', async () => {
41
64
  const validateArgs = vi.fn();
42
65
  const cmd = {
@@ -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 (only when OPENCLI_VERBOSE is set or -v flag) */
18
+ /** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
19
19
  verbose(msg: string): void;
20
- /** Debug output (only when DEBUG includes 'opencli') */
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;
@@ -8,9 +8,6 @@ import { styleText } from 'node:util';
8
8
  function isVerbose() {
9
9
  return !!process.env.OPENCLI_VERBOSE;
10
10
  }
11
- function isDebug() {
12
- return !!process.env.DEBUG?.includes('opencli');
13
- }
14
11
  export const log = {
15
12
  /** Informational message (always shown) */
16
13
  info(msg) {
@@ -32,17 +29,15 @@ export const log = {
32
29
  error(msg) {
33
30
  process.stderr.write(`${styleText('red', '✖')} ${msg}\n`);
34
31
  },
35
- /** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
32
+ /** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
36
33
  verbose(msg) {
37
34
  if (isVerbose()) {
38
35
  process.stderr.write(`${styleText('dim', '[verbose]')} ${msg}\n`);
39
36
  }
40
37
  },
41
- /** Debug output (only when DEBUG includes 'opencli') */
38
+ /** Alias for verbose output. */
42
39
  debug(msg) {
43
- if (isDebug()) {
44
- process.stderr.write(`${styleText('dim', '[debug]')} ${msg}\n`);
45
- }
40
+ this.verbose(msg);
46
41
  },
47
42
  /** Step-style debug (for pipeline steps, etc.) */
48
43
  step(stepNum, total, op, preview = '') {
@@ -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) {
@@ -2,17 +2,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { render } from './output.js';
3
3
  describe('output TTY detection', () => {
4
4
  const originalIsTTY = process.stdout.isTTY;
5
- const originalEnv = process.env.OUTPUT;
6
5
  let logSpy;
7
6
  beforeEach(() => {
8
7
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
9
8
  });
10
9
  afterEach(() => {
11
10
  Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true });
12
- if (originalEnv === undefined)
13
- delete process.env.OUTPUT;
14
- else
15
- process.env.OUTPUT = originalEnv;
16
11
  logSpy.mockRestore();
17
12
  });
18
13
  it('outputs YAML in non-TTY when format is default table', () => {
@@ -35,22 +30,6 @@ describe('output TTY detection', () => {
35
30
  const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
36
31
  expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
37
32
  });
38
- it('OUTPUT env var overrides default table in non-TTY', () => {
39
- Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
40
- process.env.OUTPUT = 'json';
41
- render([{ name: 'alice' }], { fmt: 'table' });
42
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
43
- expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
44
- });
45
- it('explicit -f flag takes precedence over OUTPUT env var', () => {
46
- Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
47
- process.env.OUTPUT = 'json';
48
- render([{ name: 'alice' }], { fmt: 'csv', fmtExplicit: true });
49
- const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
50
- expect(out).toContain('name');
51
- expect(out).toContain('alice');
52
- expect(out).not.toContain('"name"'); // not JSON
53
- });
54
33
  it('explicit -f table overrides non-TTY auto-downgrade', () => {
55
34
  Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
56
35
  render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
@@ -40,7 +40,7 @@ export async function stepMap(_page, params, data, args) {
40
40
  for (const [key, template] of Object.entries(templateParams)) {
41
41
  if (key === 'select')
42
42
  continue;
43
- row[key] = render(template, { args, data: source, item, index: i });
43
+ row[key] = render(template, { args, data: source, root: data, item, index: i });
44
44
  }
45
45
  result.push(row);
46
46
  }
@@ -4,6 +4,7 @@
4
4
  export interface RenderContext {
5
5
  args?: Record<string, unknown>;
6
6
  data?: unknown;
7
+ root?: unknown;
7
8
  item?: unknown;
8
9
  index?: number;
9
10
  }