@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.
- package/README.md +16 -16
- package/README.zh-CN.md +28 -15
- package/cli-manifest.json +547 -10
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -37
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +2 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/types.d.ts +1 -1
- 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
|
+
});
|
package/dist/src/browser.test.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 (
|
|
265
|
-
log.error(`
|
|
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
|
-
|
|
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?.()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
* opencli daemon
|
|
2
|
+
* CLI commands for daemon lifecycle:
|
|
3
|
+
* opencli daemon status — show 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
|
|
3
|
-
* opencli daemon
|
|
2
|
+
* CLI commands for daemon lifecycle:
|
|
3
|
+
* opencli daemon status — show 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 {
|
|
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(() => {
|
package/dist/src/daemon.js
CHANGED
|
@@ -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,
|
package/dist/src/doctor.d.ts
CHANGED
package/dist/src/doctor.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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';
|
package/dist/src/doctor.test.js
CHANGED
|
@@ -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: "
|
|
40
|
-
linux: "
|
|
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
|
package/dist/src/logger.d.ts
CHANGED
|
@@ -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
|
|
18
|
+
/** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
|
|
19
19
|
verbose(msg: string): void;
|
|
20
|
-
/**
|
|
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;
|
package/dist/src/logger.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { styleText } from 'node:util';
|
|
8
8
|
function isVerbose() {
|
|
9
|
-
return !!process.env.OPENCLI_VERBOSE
|
|
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
|
|
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
|
-
/**
|
|
38
|
+
/** Alias for verbose output. */
|
|
39
39
|
debug(msg) {
|
|
40
40
|
this.verbose(msg);
|
|
41
41
|
},
|
package/dist/src/output.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|