@jackwener/opencli 1.7.18 → 1.7.20
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 +18 -17
- package/README.zh-CN.md +16 -18
- package/cli-manifest.json +311 -186
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- package/clis/rednote/search.js +6 -2
- package/clis/twitter/bookmark-folder.js +8 -4
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmarks.js +12 -4
- package/clis/twitter/bookmarks.test.js +205 -0
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +23 -2
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/search.js +168 -13
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +3 -0
- package/dist/src/browser/daemon-client.test.js +20 -0
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +131 -89
- package/dist/src/cli.test.js +34 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +44 -13
- package/dist/src/daemon.test.js +42 -1
- package/dist/src/doctor.js +15 -16
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/external-clis.yaml +12 -3
- package/dist/src/external.d.ts +4 -0
- package/dist/src/external.js +3 -0
- package/dist/src/external.test.js +24 -1
- package/dist/src/help.d.ts +16 -1
- package/dist/src/help.js +50 -8
- package/dist/src/help.test.js +5 -1
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- package/package.json +5 -4
- package/clis/notion/export.js +0 -32
- package/clis/notion/favorites.js +0 -85
- package/clis/notion/new.js +0 -35
- package/clis/notion/read.js +0 -31
- package/clis/notion/search.js +0 -47
- package/clis/notion/sidebar.js +0 -42
- package/clis/notion/status.js +0 -17
- package/clis/notion/write.js +0 -41
package/dist/src/cli.test.js
CHANGED
|
@@ -168,6 +168,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
168
168
|
expect(data.site_adapters.sites).toEqual(['bilibili']);
|
|
169
169
|
expect(data.external_clis.count).toBeGreaterThanOrEqual(0);
|
|
170
170
|
expect(Array.isArray(data.external_clis.clis)).toBe(true);
|
|
171
|
+
expect(Array.isArray(data.external_clis.display)).toBe(true);
|
|
171
172
|
// Adapters must NOT leak into the core commands list
|
|
172
173
|
const commandNames = data.commands.map((cmd) => cmd.name);
|
|
173
174
|
expect(commandNames).not.toContain('bilibili');
|
|
@@ -349,20 +350,20 @@ describe('createProgram root help descriptions', () => {
|
|
|
349
350
|
expect(data.command).toBe('opencli browser');
|
|
350
351
|
expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
351
352
|
expect(data.command_count).toBeGreaterThan(20);
|
|
353
|
+
// `--session` is now a hidden internal option; user-facing surface is the
|
|
354
|
+
// <session> positional declared via `.usage()`. Structured help drops
|
|
355
|
+
// hidden options, so namespace_options shouldn't expose it.
|
|
356
|
+
expect(data.namespace_options).not.toEqual(expect.arrayContaining([
|
|
357
|
+
expect.objectContaining({ name: 'session' }),
|
|
358
|
+
]));
|
|
352
359
|
expect(data.namespace_options).toEqual(expect.arrayContaining([
|
|
353
|
-
expect.objectContaining({
|
|
354
|
-
name: 'session',
|
|
355
|
-
flags: '--session <name>',
|
|
356
|
-
takes_value: 'required',
|
|
357
|
-
required: true,
|
|
358
|
-
help: expect.stringContaining('required'),
|
|
359
|
-
}),
|
|
360
360
|
expect.objectContaining({
|
|
361
361
|
name: 'window',
|
|
362
362
|
flags: '--window <mode>',
|
|
363
363
|
takes_value: 'required',
|
|
364
364
|
}),
|
|
365
365
|
]));
|
|
366
|
+
expect(data.usage).toBe('opencli browser <session> <command> [options]');
|
|
366
367
|
expect(data.global_options).toEqual(expect.arrayContaining([
|
|
367
368
|
expect.objectContaining({
|
|
368
369
|
name: 'version',
|
|
@@ -375,21 +376,24 @@ describe('createProgram root help descriptions', () => {
|
|
|
375
376
|
}),
|
|
376
377
|
]));
|
|
377
378
|
const click = data.commands.find((cmd) => cmd.name === 'click');
|
|
379
|
+
// Structured help command/usage paths include the <session> positional so
|
|
380
|
+
// agents construct the correct full invocation. `name` is the leaf
|
|
381
|
+
// identifier (placeholder positionals are stripped).
|
|
378
382
|
expect(click).toMatchObject({
|
|
379
|
-
command: 'opencli browser click',
|
|
380
|
-
usage: 'opencli browser click [target] [options]',
|
|
383
|
+
command: 'opencli browser <session> click',
|
|
384
|
+
usage: 'opencli browser <session> click [target] [options]',
|
|
381
385
|
positionals: [{ name: 'target' }],
|
|
382
386
|
});
|
|
383
387
|
expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
384
388
|
const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
|
|
385
389
|
expect(tabList).toMatchObject({
|
|
386
|
-
command: 'opencli browser tab list',
|
|
387
|
-
usage: 'opencli browser tab list [options]',
|
|
390
|
+
command: 'opencli browser <session> tab list',
|
|
391
|
+
usage: 'opencli browser <session> tab list [options]',
|
|
388
392
|
command_options: [],
|
|
389
393
|
});
|
|
390
394
|
const getText = data.commands.find((cmd) => cmd.name === 'get text');
|
|
391
395
|
expect(getText).toMatchObject({
|
|
392
|
-
command: 'opencli browser get text',
|
|
396
|
+
command: 'opencli browser <session> get text',
|
|
393
397
|
positionals: [{ name: 'target' }],
|
|
394
398
|
});
|
|
395
399
|
expect(data.structured_help).toMatchObject({
|
|
@@ -413,8 +417,8 @@ describe('createProgram root help descriptions', () => {
|
|
|
413
417
|
expect(data).toMatchObject({
|
|
414
418
|
namespace: 'browser',
|
|
415
419
|
group: 'tab',
|
|
416
|
-
command: 'opencli browser tab',
|
|
417
|
-
usage: 'opencli browser tab <command> [args] [options]',
|
|
420
|
+
command: 'opencli browser <session> tab',
|
|
421
|
+
usage: 'opencli browser <session> tab <command> [args] [options]',
|
|
418
422
|
command_count: 4,
|
|
419
423
|
});
|
|
420
424
|
expect(data.commands.map((cmd) => cmd.name)).toEqual([
|
|
@@ -424,13 +428,15 @@ describe('createProgram root help descriptions', () => {
|
|
|
424
428
|
'tab select',
|
|
425
429
|
]);
|
|
426
430
|
expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
|
|
427
|
-
command: 'opencli browser tab close',
|
|
428
|
-
usage: 'opencli browser tab close [targetId] [options]',
|
|
431
|
+
command: 'opencli browser <session> tab close',
|
|
432
|
+
usage: 'opencli browser <session> tab close [targetId] [options]',
|
|
429
433
|
positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
|
|
430
434
|
});
|
|
431
|
-
|
|
435
|
+
// session is now a hidden internal option (consumed from the <session> positional).
|
|
436
|
+
// namespace_options should only list user-facing options.
|
|
437
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
432
438
|
expect(data.structured_help).toMatchObject({
|
|
433
|
-
usage: 'opencli browser tab --help -f yaml',
|
|
439
|
+
usage: 'opencli browser <session> tab --help -f yaml',
|
|
434
440
|
});
|
|
435
441
|
}
|
|
436
442
|
finally {
|
|
@@ -449,15 +455,16 @@ describe('createProgram root help descriptions', () => {
|
|
|
449
455
|
expect(data).toMatchObject({
|
|
450
456
|
namespace: 'browser',
|
|
451
457
|
name: 'click',
|
|
452
|
-
command: 'opencli browser click',
|
|
453
|
-
usage: 'opencli browser click [target] [options]',
|
|
458
|
+
command: 'opencli browser <session> click',
|
|
459
|
+
usage: 'opencli browser <session> click [target] [options]',
|
|
454
460
|
positionals: [{ name: 'target' }],
|
|
455
461
|
structured_help: {
|
|
456
|
-
usage: 'opencli browser click --help -f yaml',
|
|
462
|
+
usage: 'opencli browser <session> click --help -f yaml',
|
|
457
463
|
},
|
|
458
464
|
});
|
|
459
465
|
expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
460
|
-
|
|
466
|
+
// session is hidden; only `window` surfaces as a namespace option.
|
|
467
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
461
468
|
expect(data.global_options.map((option) => option.name)).toContain('profile');
|
|
462
469
|
}
|
|
463
470
|
finally {
|
|
@@ -898,13 +905,12 @@ describe('browser tab targeting commands', () => {
|
|
|
898
905
|
});
|
|
899
906
|
it('requires an explicit session for browser commands', async () => {
|
|
900
907
|
const program = createProgram('', '');
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
});
|
|
908
|
+
// --session is now a hidden internal flag; commander no longer guards it.
|
|
909
|
+
// The action body throws via getBrowserSession(), surfacing the
|
|
910
|
+
// <session> positional in the error message.
|
|
911
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'state']);
|
|
906
912
|
expect(mockBrowserConnect).not.toHaveBeenCalled();
|
|
907
|
-
expect(stderrSpy.mock.calls.flat().join('')).toContain(
|
|
913
|
+
expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
|
|
908
914
|
});
|
|
909
915
|
it('runs browser commands against an explicit session', async () => {
|
|
910
916
|
const program = createProgram('', '');
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
5
|
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
6
6
|
*/
|
|
7
|
-
import { styleText } from 'node:util';
|
|
8
7
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
9
8
|
import { restartDaemon } from '../browser/daemon-lifecycle.js';
|
|
10
9
|
import { formatDuration } from '../download/progress.js';
|
|
@@ -14,18 +13,18 @@ import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js
|
|
|
14
13
|
export async function daemonStatus() {
|
|
15
14
|
const status = await fetchDaemonStatus();
|
|
16
15
|
if (!status) {
|
|
17
|
-
console.log(
|
|
16
|
+
console.log('Daemon: not running');
|
|
18
17
|
return;
|
|
19
18
|
}
|
|
20
19
|
const extensionLabel = !status.extensionConnected
|
|
21
|
-
?
|
|
20
|
+
? 'disconnected'
|
|
22
21
|
: status.extensionVersion
|
|
23
|
-
?
|
|
24
|
-
:
|
|
22
|
+
? `connected (v${status.extensionVersion})`
|
|
23
|
+
: 'connected (version unknown)';
|
|
25
24
|
const daemonVersion = formatDaemonVersion(status);
|
|
26
25
|
const stale = isDaemonStale(status, PKG_VERSION);
|
|
27
|
-
console.log(`Daemon: ${stale ?
|
|
28
|
-
console.log(`Version: ${daemonVersion}${stale ?
|
|
26
|
+
console.log(`Daemon: ${stale ? 'stale' : 'running'} (PID ${status.pid})`);
|
|
27
|
+
console.log(`Version: ${daemonVersion}${stale ? ` (CLI v${PKG_VERSION}; run: opencli daemon restart)` : ''}`);
|
|
29
28
|
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
|
30
29
|
console.log(`Extension: ${extensionLabel}`);
|
|
31
30
|
if (status.profiles && status.profiles.length > 0) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const COMMAND_RESULT_UNKNOWN_CODE = "command_result_unknown";
|
|
2
|
+
export declare const COMMAND_RESULT_UNKNOWN_HINT = "Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.";
|
|
3
|
+
export declare const PROFILE_DISCONNECTED_HINT = "Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.";
|
|
4
|
+
export type DaemonFailureContract = {
|
|
5
|
+
message: string;
|
|
6
|
+
errorCode: string;
|
|
7
|
+
errorHint: string;
|
|
8
|
+
status: number;
|
|
9
|
+
countAsCommandResultUnknown: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function commandResultUnknownMessage(action: string): string;
|
|
12
|
+
export declare function buildExtensionDisconnectFailure(input: {
|
|
13
|
+
contextId: string;
|
|
14
|
+
action: string;
|
|
15
|
+
dispatched: boolean;
|
|
16
|
+
}): DaemonFailureContract;
|
|
17
|
+
export declare function buildCommandDispatchFailure(contextId: string): DaemonFailureContract;
|
|
18
|
+
export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const COMMAND_RESULT_UNKNOWN_CODE = 'command_result_unknown';
|
|
2
|
+
export const COMMAND_RESULT_UNKNOWN_HINT = 'Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.';
|
|
3
|
+
export const PROFILE_DISCONNECTED_HINT = 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.';
|
|
4
|
+
export function commandResultUnknownMessage(action) {
|
|
5
|
+
return `Browser connection dropped after the ${action} command was dispatched; it may have completed.`;
|
|
6
|
+
}
|
|
7
|
+
export function buildExtensionDisconnectFailure(input) {
|
|
8
|
+
if (input.dispatched) {
|
|
9
|
+
return {
|
|
10
|
+
message: commandResultUnknownMessage(input.action),
|
|
11
|
+
errorCode: COMMAND_RESULT_UNKNOWN_CODE,
|
|
12
|
+
errorHint: COMMAND_RESULT_UNKNOWN_HINT,
|
|
13
|
+
status: 503,
|
|
14
|
+
countAsCommandResultUnknown: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return buildCommandDispatchFailure(input.contextId);
|
|
18
|
+
}
|
|
19
|
+
export function buildCommandDispatchFailure(contextId) {
|
|
20
|
+
return {
|
|
21
|
+
message: `Browser profile "${contextId}" disconnected before command dispatch`,
|
|
22
|
+
errorCode: 'profile_disconnected',
|
|
23
|
+
errorHint: PROFILE_DISCONNECTED_HINT,
|
|
24
|
+
status: 503,
|
|
25
|
+
countAsCommandResultUnknown: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function getResponseCorsHeaders(pathname, origin) {
|
|
29
|
+
if (pathname !== '/ping')
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!origin || !origin.startsWith('chrome-extension://'))
|
|
32
|
+
return undefined;
|
|
33
|
+
return {
|
|
34
|
+
'Access-Control-Allow-Origin': origin,
|
|
35
|
+
Vary: 'Origin',
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/src/daemon.d.ts
CHANGED
package/dist/src/daemon.js
CHANGED
|
@@ -27,9 +27,11 @@ import { log } from './logger.js';
|
|
|
27
27
|
import { PKG_VERSION } from './version.js';
|
|
28
28
|
import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
|
|
29
29
|
import { recordExtensionVersion } from './update-check.js';
|
|
30
|
+
import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
|
|
30
31
|
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
31
32
|
const extensionProfiles = new Map();
|
|
32
33
|
const pending = new Map();
|
|
34
|
+
let commandResultUnknownCount = 0;
|
|
33
35
|
const LOG_BUFFER_SIZE = 200;
|
|
34
36
|
const logBuffer = [];
|
|
35
37
|
class DaemonCommandFailure extends Error {
|
|
@@ -110,7 +112,16 @@ function unregisterExtensionConnection(ws) {
|
|
|
110
112
|
if (p.contextId !== contextId)
|
|
111
113
|
continue;
|
|
112
114
|
clearTimeout(p.timer);
|
|
113
|
-
|
|
115
|
+
const failure = buildExtensionDisconnectFailure({
|
|
116
|
+
contextId,
|
|
117
|
+
action: p.action,
|
|
118
|
+
dispatched: p.dispatched,
|
|
119
|
+
});
|
|
120
|
+
if (failure.countAsCommandResultUnknown) {
|
|
121
|
+
commandResultUnknownCount++;
|
|
122
|
+
log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`);
|
|
123
|
+
}
|
|
124
|
+
p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
|
|
114
125
|
pending.delete(id);
|
|
115
126
|
}
|
|
116
127
|
}
|
|
@@ -142,16 +153,6 @@ function jsonResponse(res, status, data, extraHeaders) {
|
|
|
142
153
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
143
154
|
res.end(JSON.stringify(data));
|
|
144
155
|
}
|
|
145
|
-
export function getResponseCorsHeaders(pathname, origin) {
|
|
146
|
-
if (pathname !== '/ping')
|
|
147
|
-
return undefined;
|
|
148
|
-
if (!origin || !origin.startsWith('chrome-extension://'))
|
|
149
|
-
return undefined;
|
|
150
|
-
return {
|
|
151
|
-
'Access-Control-Allow-Origin': origin,
|
|
152
|
-
Vary: 'Origin',
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
156
|
async function handleRequest(req, res) {
|
|
156
157
|
// ─── Security: Origin & custom-header check ──────────────────────
|
|
157
158
|
// Block browser-based CSRF: browsers always send an Origin header on
|
|
@@ -219,6 +220,7 @@ async function handleRequest(req, res) {
|
|
|
219
220
|
profileDisconnected: route.errorCode === 'profile_disconnected',
|
|
220
221
|
profiles,
|
|
221
222
|
pending: pending.size,
|
|
223
|
+
commandResultUnknown: commandResultUnknownCount,
|
|
222
224
|
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
|
223
225
|
port: PORT,
|
|
224
226
|
});
|
|
@@ -277,8 +279,37 @@ async function handleRequest(req, res) {
|
|
|
277
279
|
pending.delete(body.id);
|
|
278
280
|
reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
|
|
279
281
|
}, timeoutMs);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
+
const entry = {
|
|
283
|
+
contextId: route.connection.contextId,
|
|
284
|
+
action: typeof body.action === 'string' ? body.action : 'unknown',
|
|
285
|
+
dispatched: false,
|
|
286
|
+
resolve,
|
|
287
|
+
reject,
|
|
288
|
+
timer,
|
|
289
|
+
};
|
|
290
|
+
pending.set(body.id, entry);
|
|
291
|
+
const failBeforeDispatch = (err) => {
|
|
292
|
+
if (pending.get(body.id) !== entry)
|
|
293
|
+
return;
|
|
294
|
+
const failure = buildCommandDispatchFailure(entry.contextId);
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
pending.delete(body.id);
|
|
297
|
+
reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
|
|
298
|
+
log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
route.connection.ws.send(JSON.stringify(body), (err) => {
|
|
302
|
+
if (err && !entry.dispatched)
|
|
303
|
+
failBeforeDispatch(err);
|
|
304
|
+
});
|
|
305
|
+
// Once ws accepts the frame, the command may execute even if the
|
|
306
|
+
// result is later lost; do not downgrade later disconnects to a
|
|
307
|
+
// pre-dispatch failure just because no result/ack has arrived yet.
|
|
308
|
+
entry.dispatched = true;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
failBeforeDispatch(err);
|
|
312
|
+
}
|
|
282
313
|
});
|
|
283
314
|
jsonResponse(res, 200, result);
|
|
284
315
|
}
|
package/dist/src/daemon.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { getResponseCorsHeaders } from './daemon.js';
|
|
2
|
+
import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT, buildCommandDispatchFailure, buildExtensionDisconnectFailure, commandResultUnknownMessage, getResponseCorsHeaders, } from './daemon-utils.js';
|
|
3
3
|
describe('getResponseCorsHeaders', () => {
|
|
4
4
|
it('allows the Browser Bridge extension origin to read /ping', () => {
|
|
5
5
|
expect(getResponseCorsHeaders('/ping', 'chrome-extension://abc123')).toEqual({
|
|
@@ -17,3 +17,44 @@ describe('getResponseCorsHeaders', () => {
|
|
|
17
17
|
expect(getResponseCorsHeaders('/command', 'chrome-extension://abc123')).toBeUndefined();
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
|
+
describe('daemon command dispatch', () => {
|
|
21
|
+
it('uses a distinct command_result_unknown contract for ambiguous dispatched commands', () => {
|
|
22
|
+
expect(COMMAND_RESULT_UNKNOWN_CODE).toBe('command_result_unknown');
|
|
23
|
+
expect(commandResultUnknownMessage('navigate')).toContain('navigate command was dispatched');
|
|
24
|
+
expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Inspect the browser/session state');
|
|
25
|
+
expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Do not blindly retry write commands');
|
|
26
|
+
});
|
|
27
|
+
it('classifies dispatched extension disconnects as command_result_unknown', () => {
|
|
28
|
+
expect(buildExtensionDisconnectFailure({
|
|
29
|
+
contextId: 'work',
|
|
30
|
+
action: 'navigate',
|
|
31
|
+
dispatched: true,
|
|
32
|
+
})).toEqual({
|
|
33
|
+
message: 'Browser connection dropped after the navigate command was dispatched; it may have completed.',
|
|
34
|
+
errorCode: 'command_result_unknown',
|
|
35
|
+
errorHint: COMMAND_RESULT_UNKNOWN_HINT,
|
|
36
|
+
status: 503,
|
|
37
|
+
countAsCommandResultUnknown: true,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('classifies pre-dispatch extension disconnects as profile_disconnected', () => {
|
|
41
|
+
expect(buildExtensionDisconnectFailure({
|
|
42
|
+
contextId: 'work',
|
|
43
|
+
action: 'navigate',
|
|
44
|
+
dispatched: false,
|
|
45
|
+
})).toMatchObject({
|
|
46
|
+
message: 'Browser profile "work" disconnected before command dispatch',
|
|
47
|
+
errorCode: 'profile_disconnected',
|
|
48
|
+
status: 503,
|
|
49
|
+
countAsCommandResultUnknown: false,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('classifies ws.send dispatch failures as profile_disconnected', () => {
|
|
53
|
+
expect(buildCommandDispatchFailure('work')).toMatchObject({
|
|
54
|
+
message: 'Browser profile "work" disconnected before command dispatch',
|
|
55
|
+
errorCode: 'profile_disconnected',
|
|
56
|
+
status: 503,
|
|
57
|
+
countAsCommandResultUnknown: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/src/doctor.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture.
|
|
5
5
|
*/
|
|
6
|
-
import { styleText } from 'node:util';
|
|
7
6
|
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
8
7
|
import { BrowserBridge } from './browser/index.js';
|
|
9
8
|
import { getDaemonHealth } from './browser/daemon-client.js';
|
|
@@ -168,13 +167,13 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
168
167
|
};
|
|
169
168
|
}
|
|
170
169
|
export function renderBrowserDoctorReport(report) {
|
|
171
|
-
const lines = [
|
|
170
|
+
const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor` + ` (${getRuntimeLabel()})`, ''];
|
|
172
171
|
// Daemon status
|
|
173
172
|
const daemonIcon = report.daemonFlaky
|
|
174
|
-
?
|
|
173
|
+
? '[WARN]'
|
|
175
174
|
: report.daemonStale
|
|
176
|
-
?
|
|
177
|
-
: report.daemonRunning ?
|
|
175
|
+
? '[WARN]'
|
|
176
|
+
: report.daemonRunning ? '[OK]' : '[MISSING]';
|
|
178
177
|
const daemonLabel = report.daemonFlaky
|
|
179
178
|
? 'unstable (running during live check, then stopped)'
|
|
180
179
|
: report.daemonRunning
|
|
@@ -185,47 +184,47 @@ export function renderBrowserDoctorReport(report) {
|
|
|
185
184
|
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
|
|
186
185
|
// Extension status
|
|
187
186
|
const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
|
|
188
|
-
?
|
|
189
|
-
: report.extensionConnected ?
|
|
187
|
+
? '[WARN]'
|
|
188
|
+
: report.extensionConnected ? '[OK]' : '[MISSING]';
|
|
190
189
|
const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
|
|
191
|
-
?
|
|
190
|
+
? ` → v${report.latestExtensionVersion} available`
|
|
192
191
|
: '';
|
|
193
192
|
const extVersion = !report.extensionConnected
|
|
194
193
|
? ''
|
|
195
194
|
: report.extensionVersion
|
|
196
|
-
?
|
|
197
|
-
:
|
|
195
|
+
? ` (v${report.extensionVersion})` + extUpdateHint
|
|
196
|
+
: ' (version unknown)';
|
|
198
197
|
const extLabel = report.extensionFlaky
|
|
199
198
|
? 'unstable (connected during live check, then disconnected)'
|
|
200
199
|
: report.extensionConnected ? 'connected' : 'not connected';
|
|
201
200
|
lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
|
|
202
201
|
if (report.profiles && report.profiles.length > 0) {
|
|
203
202
|
const config = loadProfileConfig();
|
|
204
|
-
lines.push('',
|
|
203
|
+
lines.push('', 'Profiles:');
|
|
205
204
|
for (const profile of report.profiles) {
|
|
206
205
|
const alias = aliasForContextId(config, profile.contextId);
|
|
207
206
|
const aliasText = alias ? ` (${alias})` : '';
|
|
208
207
|
const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
|
|
209
208
|
const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
|
|
210
|
-
lines.push(
|
|
209
|
+
lines.push(` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`);
|
|
211
210
|
}
|
|
212
211
|
}
|
|
213
212
|
// Connectivity
|
|
214
213
|
if (report.connectivity) {
|
|
215
|
-
const connIcon = report.connectivity.ok ?
|
|
214
|
+
const connIcon = report.connectivity.ok ? '[OK]' : '[FAIL]';
|
|
216
215
|
const detail = report.connectivity.ok
|
|
217
216
|
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
218
217
|
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
219
218
|
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
220
219
|
}
|
|
221
220
|
if (report.issues.length) {
|
|
222
|
-
lines.push('',
|
|
221
|
+
lines.push('', 'Issues:');
|
|
223
222
|
for (const issue of report.issues) {
|
|
224
|
-
lines.push(
|
|
223
|
+
lines.push(` • ${issue}`);
|
|
225
224
|
}
|
|
226
225
|
}
|
|
227
226
|
else if (report.daemonRunning && report.extensionConnected) {
|
|
228
|
-
lines.push('',
|
|
227
|
+
lines.push('', 'Everything looks good!');
|
|
229
228
|
}
|
|
230
229
|
return lines.join('\n');
|
|
231
230
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Download progress display: terminal progress bars, status updates.
|
|
3
3
|
*/
|
|
4
|
-
import { styleText } from 'node:util';
|
|
5
4
|
/**
|
|
6
5
|
* Format bytes as human-readable string (KB, MB, GB).
|
|
7
6
|
*/
|
|
@@ -35,33 +34,38 @@ export function formatDuration(ms) {
|
|
|
35
34
|
* Create a simple progress bar for terminal display.
|
|
36
35
|
*/
|
|
37
36
|
export function createProgressBar(filename, index, total) {
|
|
38
|
-
const prefix =
|
|
37
|
+
const prefix = `[${index + 1}/${total}]`;
|
|
39
38
|
const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
|
|
40
39
|
return {
|
|
41
40
|
update(current, totalBytes, label) {
|
|
42
|
-
const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0;
|
|
41
|
+
const percent = clampPercent(totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0);
|
|
43
42
|
const bar = createBar(percent);
|
|
44
43
|
const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
|
|
45
44
|
const extra = label ? ` ${label}` : '';
|
|
46
45
|
process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
|
|
47
46
|
},
|
|
48
47
|
complete(success, message) {
|
|
49
|
-
const icon = success ?
|
|
50
|
-
const msg = message ? ` ${
|
|
48
|
+
const icon = success ? '✓' : '✗';
|
|
49
|
+
const msg = message ? ` ${message}` : '';
|
|
51
50
|
process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
|
|
52
51
|
},
|
|
53
52
|
fail(error) {
|
|
54
|
-
process.stderr.write(`\r${prefix}
|
|
53
|
+
process.stderr.write(`\r${prefix} ✗ ${truncatedName} ${error}\n`);
|
|
55
54
|
},
|
|
56
55
|
};
|
|
57
56
|
}
|
|
57
|
+
function clampPercent(percent) {
|
|
58
|
+
if (!Number.isFinite(percent))
|
|
59
|
+
return 0;
|
|
60
|
+
return Math.max(0, Math.min(100, percent));
|
|
61
|
+
}
|
|
58
62
|
/**
|
|
59
63
|
* Create a progress bar string.
|
|
60
64
|
*/
|
|
61
65
|
function createBar(percent, width = 20) {
|
|
62
66
|
const filled = Math.round((percent / 100) * width);
|
|
63
67
|
const empty = width - filled;
|
|
64
|
-
return
|
|
68
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
67
71
|
* Multi-file download progress tracker.
|
|
@@ -98,19 +102,19 @@ export class DownloadProgressTracker {
|
|
|
98
102
|
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
99
103
|
const parts = [];
|
|
100
104
|
if (this.completed > 0) {
|
|
101
|
-
parts.push(
|
|
105
|
+
parts.push(`${this.completed} downloaded`);
|
|
102
106
|
}
|
|
103
107
|
if (this.skipped > 0) {
|
|
104
|
-
parts.push(
|
|
108
|
+
parts.push(`${this.skipped} skipped`);
|
|
105
109
|
}
|
|
106
110
|
if (this.failed > 0) {
|
|
107
|
-
parts.push(
|
|
111
|
+
parts.push(`${this.failed} failed`);
|
|
108
112
|
}
|
|
109
113
|
return `${parts.join(', ')} in ${elapsed}`;
|
|
110
114
|
}
|
|
111
115
|
finish() {
|
|
112
116
|
if (this.verbose) {
|
|
113
|
-
process.stderr.write(`\
|
|
117
|
+
process.stderr.write(`\nDownload complete: ${this.getSummary()}\n`);
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createProgressBar } from './progress.js';
|
|
3
|
+
describe('download progress display', () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
it('clamps percentages above 100 to keep the progress bar renderable', () => {
|
|
8
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
9
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
10
|
+
expect(() => progress.update(150, 100)).not.toThrow();
|
|
11
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('100%'));
|
|
12
|
+
});
|
|
13
|
+
it('clamps negative percentages to zero', () => {
|
|
14
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
15
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
16
|
+
expect(() => progress.update(-10, 100)).not.toThrow();
|
|
17
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
18
|
+
});
|
|
19
|
+
it('renders zero percent when the total size is unknown', () => {
|
|
20
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
21
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
22
|
+
expect(() => progress.update(50, 0)).not.toThrow();
|
|
23
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -12,7 +12,6 @@ export const builtinApps = {
|
|
|
12
12
|
cursor: { port: 9226, processName: 'Cursor', bundleId: 'com.todesktop.runtime.Cursor', displayName: 'Cursor' },
|
|
13
13
|
codex: { port: 9222, processName: 'Codex', bundleId: 'com.openai.codex', displayName: 'Codex' },
|
|
14
14
|
chatwise: { port: 9228, processName: 'ChatWise', bundleId: 'com.chatwise.app', displayName: 'ChatWise' },
|
|
15
|
-
notion: { port: 9230, processName: 'Notion', bundleId: 'notion.id', displayName: 'Notion' },
|
|
16
15
|
'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
|
|
17
16
|
'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
|
|
18
17
|
antigravity: {
|
|
@@ -23,6 +23,7 @@ describe('electron-apps registry', () => {
|
|
|
23
23
|
});
|
|
24
24
|
it('isElectronApp returns false for non-Electron sites', () => {
|
|
25
25
|
expect(isElectronApp('bilibili')).toBe(false);
|
|
26
|
+
expect(isElectronApp('notion')).toBe(false);
|
|
26
27
|
expect(isElectronApp('unknown-app')).toBe(false);
|
|
27
28
|
});
|
|
28
29
|
it('loadApps merges user config additively', () => {
|
package/dist/src/execution.js
CHANGED
|
@@ -467,9 +467,7 @@ function normalizeBooleanOption(name, raw) {
|
|
|
467
467
|
function resolveKeepTab(siteSession, rawOption) {
|
|
468
468
|
if (siteSession === 'persistent')
|
|
469
469
|
return true;
|
|
470
|
-
return normalizeBooleanOption('--keep-tab', rawOption)
|
|
471
|
-
?? normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
|
|
472
|
-
?? false;
|
|
470
|
+
return normalizeBooleanOption('--keep-tab', rawOption) ?? false;
|
|
473
471
|
}
|
|
474
472
|
function normalizeWindowMode(name, raw) {
|
|
475
473
|
if (raw === undefined || raw === '')
|