@jackwener/opencli 1.7.19 → 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 +11 -9
- package/README.zh-CN.md +9 -10
- package/cli-manifest.json +6 -177
- package/clis/twitter/bookmark-folder.js +5 -3
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmarks.js +9 -3
- package/clis/twitter/bookmarks.test.js +205 -0
- 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/cli.js +8 -3
- package/dist/src/cli.test.js +1 -0
- 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/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- 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 +5 -1
- package/dist/src/help.js +4 -3
- package/dist/src/help.test.js +5 -1
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './bookmarks.js';
|
|
3
|
+
|
|
4
|
+
const { parseBookmarks, extractBookmarkTweet } = __test__;
|
|
5
|
+
|
|
6
|
+
describe('twitter bookmarks parser', () => {
|
|
7
|
+
it('extracts a baseline tweet with no media (has_media false, media_urls empty)', () => {
|
|
8
|
+
const tweet = extractBookmarkTweet({
|
|
9
|
+
rest_id: '1',
|
|
10
|
+
legacy: {
|
|
11
|
+
full_text: 'plain bookmark',
|
|
12
|
+
favorite_count: 5,
|
|
13
|
+
retweet_count: 1,
|
|
14
|
+
bookmark_count: 2,
|
|
15
|
+
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
|
|
16
|
+
},
|
|
17
|
+
core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
|
|
18
|
+
}, new Set());
|
|
19
|
+
expect(tweet).toEqual({
|
|
20
|
+
id: '1',
|
|
21
|
+
author: 'alice',
|
|
22
|
+
name: 'Alice',
|
|
23
|
+
text: 'plain bookmark',
|
|
24
|
+
likes: 5,
|
|
25
|
+
retweets: 1,
|
|
26
|
+
bookmarks: 2,
|
|
27
|
+
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
|
|
28
|
+
url: 'https://x.com/alice/status/1',
|
|
29
|
+
has_media: false,
|
|
30
|
+
media_urls: [],
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('includes photo media URLs from extended_entities', () => {
|
|
35
|
+
const tweet = extractBookmarkTweet({
|
|
36
|
+
rest_id: '101',
|
|
37
|
+
legacy: {
|
|
38
|
+
full_text: 'pic bookmark',
|
|
39
|
+
extended_entities: {
|
|
40
|
+
media: [
|
|
41
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
|
|
42
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
core: { user_results: { result: { legacy: { screen_name: 'bob' } } } },
|
|
47
|
+
}, new Set());
|
|
48
|
+
expect(tweet?.has_media).toBe(true);
|
|
49
|
+
expect(tweet?.media_urls).toEqual([
|
|
50
|
+
'https://pbs.twimg.com/media/abc.jpg',
|
|
51
|
+
'https://pbs.twimg.com/media/def.jpg',
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts mp4 variant URL for video media', () => {
|
|
56
|
+
const tweet = extractBookmarkTweet({
|
|
57
|
+
rest_id: '102',
|
|
58
|
+
legacy: {
|
|
59
|
+
full_text: 'video bookmark',
|
|
60
|
+
extended_entities: {
|
|
61
|
+
media: [{
|
|
62
|
+
type: 'video',
|
|
63
|
+
media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
|
|
64
|
+
video_info: {
|
|
65
|
+
variants: [
|
|
66
|
+
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
|
|
67
|
+
{ content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
|
|
68
|
+
{ content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
}],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
core: { user_results: { result: { legacy: { screen_name: 'carol' } } } },
|
|
75
|
+
}, new Set());
|
|
76
|
+
expect(tweet?.has_media).toBe(true);
|
|
77
|
+
expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to entities.media when extended_entities is absent', () => {
|
|
81
|
+
const tweet = extractBookmarkTweet({
|
|
82
|
+
rest_id: '103',
|
|
83
|
+
legacy: {
|
|
84
|
+
full_text: 'entities-only media',
|
|
85
|
+
entities: {
|
|
86
|
+
media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/legacy.jpg' }],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
|
|
90
|
+
}, new Set());
|
|
91
|
+
expect(tweet?.has_media).toBe(true);
|
|
92
|
+
expect(tweet?.media_urls).toEqual(['https://pbs.twimg.com/media/legacy.jpg']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('prefers note_tweet text over truncated full_text', () => {
|
|
96
|
+
const tweet = extractBookmarkTweet({
|
|
97
|
+
rest_id: '2',
|
|
98
|
+
legacy: { full_text: 'short text…', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
|
|
99
|
+
note_tweet: { note_tweet_results: { result: { text: 'full long-form text body' } } },
|
|
100
|
+
core: { user_results: { result: { core: { screen_name: 'erin' } } } },
|
|
101
|
+
}, new Set());
|
|
102
|
+
expect(tweet?.text).toBe('full long-form text body');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('deduplicates tweets across the seen Set', () => {
|
|
106
|
+
const data = {
|
|
107
|
+
data: {
|
|
108
|
+
bookmark_timeline_v2: {
|
|
109
|
+
timeline: {
|
|
110
|
+
instructions: [{
|
|
111
|
+
entries: [
|
|
112
|
+
{
|
|
113
|
+
entryId: 'tweet-3',
|
|
114
|
+
content: {
|
|
115
|
+
itemContent: {
|
|
116
|
+
tweet_results: {
|
|
117
|
+
result: {
|
|
118
|
+
rest_id: '3',
|
|
119
|
+
legacy: { full_text: 'first', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
|
|
120
|
+
core: { user_results: { result: { legacy: { screen_name: 'frank' } } } },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
entryId: 'tweet-3-dup',
|
|
128
|
+
content: {
|
|
129
|
+
itemContent: {
|
|
130
|
+
tweet_results: {
|
|
131
|
+
result: {
|
|
132
|
+
rest_id: '3',
|
|
133
|
+
legacy: { full_text: 'duplicate' },
|
|
134
|
+
core: { user_results: { result: { legacy: { screen_name: 'frank' } } } },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const { tweets } = parseBookmarks(data, seen);
|
|
148
|
+
expect(tweets).toHaveLength(1);
|
|
149
|
+
expect(tweets[0].text).toBe('first');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('extracts cursor + tweets from the bookmark_timeline_v2 envelope', () => {
|
|
153
|
+
const data = {
|
|
154
|
+
data: {
|
|
155
|
+
bookmark_timeline_v2: {
|
|
156
|
+
timeline: {
|
|
157
|
+
instructions: [
|
|
158
|
+
{
|
|
159
|
+
type: 'TimelineAddEntries',
|
|
160
|
+
entries: [
|
|
161
|
+
{
|
|
162
|
+
entryId: 'tweet-4',
|
|
163
|
+
content: {
|
|
164
|
+
itemContent: {
|
|
165
|
+
tweet_results: {
|
|
166
|
+
result: {
|
|
167
|
+
rest_id: '4',
|
|
168
|
+
legacy: {
|
|
169
|
+
full_text: 'envelope tweet',
|
|
170
|
+
favorite_count: 1,
|
|
171
|
+
retweet_count: 0,
|
|
172
|
+
bookmark_count: 0,
|
|
173
|
+
extended_entities: {
|
|
174
|
+
media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/x.jpg' }],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
core: { user_results: { result: { legacy: { screen_name: 'gina' } } } },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
entryId: 'cursor-bottom-Y',
|
|
185
|
+
content: { __typename: 'TimelineTimelineCursor', cursorType: 'Bottom', value: 'NEXT' },
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const { tweets, nextCursor } = parseBookmarks(data, new Set());
|
|
195
|
+
expect(tweets).toHaveLength(1);
|
|
196
|
+
expect(tweets[0].id).toBe('4');
|
|
197
|
+
expect(tweets[0].has_media).toBe(true);
|
|
198
|
+
expect(tweets[0].media_urls).toEqual(['https://pbs.twimg.com/media/x.jpg']);
|
|
199
|
+
expect(nextCursor).toBe('NEXT');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns empty tweets + null cursor for unknown envelope', () => {
|
|
203
|
+
expect(parseBookmarks({}, new Set())).toEqual({ tweets: [], nextCursor: null });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -105,6 +105,9 @@ async function sendCommandRaw(action, params) {
|
|
|
105
105
|
});
|
|
106
106
|
const result = (await res.json());
|
|
107
107
|
if (!result.ok) {
|
|
108
|
+
if (result.errorCode === 'command_result_unknown') {
|
|
109
|
+
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
|
|
110
|
+
}
|
|
108
111
|
const isDuplicateCommandId = res.status === 409
|
|
109
112
|
|| (result.error ?? '').includes('Duplicate command id');
|
|
110
113
|
if (isDuplicateCommandId && attempt < maxRetries) {
|
|
@@ -176,4 +176,24 @@ describe('daemon-client', () => {
|
|
|
176
176
|
});
|
|
177
177
|
expect(ids[0]).not.toBe(ids[1]);
|
|
178
178
|
});
|
|
179
|
+
it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
|
|
180
|
+
const fetchMock = vi.mocked(fetch);
|
|
181
|
+
fetchMock.mockResolvedValue({
|
|
182
|
+
ok: false,
|
|
183
|
+
status: 503,
|
|
184
|
+
json: () => Promise.resolve({
|
|
185
|
+
id: 'server',
|
|
186
|
+
ok: false,
|
|
187
|
+
errorCode: 'command_result_unknown',
|
|
188
|
+
error: 'Extension disconnected after command timeout',
|
|
189
|
+
errorHint: 'Inspect state before retrying.',
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({
|
|
193
|
+
name: 'BrowserCommandError',
|
|
194
|
+
code: 'command_result_unknown',
|
|
195
|
+
hint: 'Inspect state before retrying.',
|
|
196
|
+
});
|
|
197
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
179
199
|
});
|
package/dist/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { serializeCommand, formatArgSummary } from './serialization.js';
|
|
|
15
15
|
import { render as renderOutput } from './output.js';
|
|
16
16
|
import { PKG_VERSION } from './version.js';
|
|
17
17
|
import { printCompletionScript } from './completion.js';
|
|
18
|
-
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
18
|
+
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
|
|
19
19
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
20
20
|
import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
|
|
21
21
|
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
|
|
@@ -3005,6 +3005,7 @@ cli({
|
|
|
3005
3005
|
.action((opts) => {
|
|
3006
3006
|
const rows = loadExternalClis().map((ext) => ({
|
|
3007
3007
|
name: ext.name,
|
|
3008
|
+
package: ext.package ?? '',
|
|
3008
3009
|
binary: ext.binary,
|
|
3009
3010
|
installed: isBinaryInstalled(ext.binary),
|
|
3010
3011
|
description: ext.description ?? '',
|
|
@@ -3013,7 +3014,7 @@ cli({
|
|
|
3013
3014
|
}));
|
|
3014
3015
|
renderOutput(rows, {
|
|
3015
3016
|
fmt: opts.format,
|
|
3016
|
-
columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
|
|
3017
|
+
columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'],
|
|
3017
3018
|
title: 'opencli/external/list',
|
|
3018
3019
|
source: 'opencli external list',
|
|
3019
3020
|
});
|
|
@@ -3067,6 +3068,10 @@ cli({
|
|
|
3067
3068
|
// Classification derives from each adapter's `domain` field — see classifyAdapter.
|
|
3068
3069
|
// External CLIs are taken from the externalClis registry (passthrough binaries).
|
|
3069
3070
|
const externalNames = externalClis.map(ext => ext.name);
|
|
3071
|
+
const externalHelpEntries = externalClis.map(ext => ({
|
|
3072
|
+
name: ext.name,
|
|
3073
|
+
label: formatExternalCliLabel(ext),
|
|
3074
|
+
}));
|
|
3070
3075
|
const siteDomains = new Map();
|
|
3071
3076
|
for (const [, cmd] of getRegistry()) {
|
|
3072
3077
|
if (!siteDomains.has(cmd.site))
|
|
@@ -3080,7 +3085,7 @@ cli({
|
|
|
3080
3085
|
else
|
|
3081
3086
|
sites.push(site);
|
|
3082
3087
|
}
|
|
3083
|
-
const adapterGroups = { external:
|
|
3088
|
+
const adapterGroups = { external: externalHelpEntries, apps, sites };
|
|
3084
3089
|
const adapterNameSet = new Set([...externalNames, ...siteNames]);
|
|
3085
3090
|
installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
|
|
3086
3091
|
installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
|
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');
|
|
@@ -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
|
+
});
|
|
@@ -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', () => {
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
install:
|
|
15
15
|
mac: "brew install --cask obsidian"
|
|
16
16
|
|
|
17
|
+
- name: ntn
|
|
18
|
+
binary: ntn
|
|
19
|
+
description: "Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments"
|
|
20
|
+
homepage: "https://ntn.dev"
|
|
21
|
+
tags: [notion, notes, knowledge, productivity]
|
|
22
|
+
|
|
17
23
|
- name: docker
|
|
18
24
|
binary: docker
|
|
19
25
|
description: "Docker command-line interface"
|
|
@@ -55,24 +61,27 @@
|
|
|
55
61
|
install:
|
|
56
62
|
default: "npm install -g vercel"
|
|
57
63
|
|
|
58
|
-
- name: tg
|
|
64
|
+
- name: tg
|
|
59
65
|
binary: tg
|
|
66
|
+
package: tg-cli
|
|
60
67
|
description: "Telegram CLI — local-first sync, search, export via MTProto for AI agents"
|
|
61
68
|
homepage: "https://github.com/jackwener/tg-cli"
|
|
62
69
|
tags: [telegram, messaging, search, export, ai-agent]
|
|
63
70
|
install:
|
|
64
71
|
default: "uv tool install kabi-tg-cli"
|
|
65
72
|
|
|
66
|
-
- name: discord
|
|
73
|
+
- name: discord
|
|
67
74
|
binary: discord
|
|
75
|
+
package: discord-cli
|
|
68
76
|
description: "Discord CLI — local-first sync, search, export via SQLite for AI agents"
|
|
69
77
|
homepage: "https://github.com/jackwener/discord-cli"
|
|
70
78
|
tags: [discord, messaging, search, export, ai-agent]
|
|
71
79
|
install:
|
|
72
80
|
default: "uv tool install kabi-discord-cli"
|
|
73
81
|
|
|
74
|
-
- name: wx
|
|
82
|
+
- name: wx
|
|
75
83
|
binary: wx
|
|
84
|
+
package: wx-cli
|
|
76
85
|
description: "WeChat local data CLI — sessions, messages, search, contacts, export for AI agents"
|
|
77
86
|
homepage: "https://github.com/jackwener/wx-cli"
|
|
78
87
|
tags: [wechat, messaging, search, export, ai-agent]
|
package/dist/src/external.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ export interface ExternalCliInstall {
|
|
|
5
5
|
default?: string;
|
|
6
6
|
}
|
|
7
7
|
export interface ExternalCliConfig {
|
|
8
|
+
/** User-facing OpenCLI subcommand and, by default, the executable name. */
|
|
8
9
|
name: string;
|
|
9
10
|
binary: string;
|
|
11
|
+
/** Distribution/project name when it differs from the executable name. */
|
|
12
|
+
package?: string;
|
|
10
13
|
description?: string;
|
|
11
14
|
homepage?: string;
|
|
12
15
|
tags?: string[];
|
|
@@ -15,6 +18,7 @@ export interface ExternalCliConfig {
|
|
|
15
18
|
export declare function loadExternalClis(): ExternalCliConfig[];
|
|
16
19
|
export declare function isBinaryInstalled(binary: string): boolean;
|
|
17
20
|
export declare function getInstallCmd(installConfig?: ExternalCliInstall): string | null;
|
|
21
|
+
export declare function formatExternalCliLabel(cli: ExternalCliConfig): string;
|
|
18
22
|
/**
|
|
19
23
|
* Safely parses a command string into a binary and argument list.
|
|
20
24
|
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
package/dist/src/external.js
CHANGED
|
@@ -70,6 +70,9 @@ export function getInstallCmd(installConfig) {
|
|
|
70
70
|
return installConfig.default;
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
|
+
export function formatExternalCliLabel(cli) {
|
|
74
|
+
return cli.package && cli.package !== cli.name ? `${cli.name}(${cli.package})` : cli.name;
|
|
75
|
+
}
|
|
73
76
|
/**
|
|
74
77
|
* Safely parses a command string into a binary and argument list.
|
|
75
78
|
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
2
6
|
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
|
|
3
7
|
mockExecFileSync: vi.fn(),
|
|
4
8
|
mockPlatform: vi.fn(() => 'darwin'),
|
|
@@ -14,7 +18,8 @@ vi.mock('node:os', async () => {
|
|
|
14
18
|
platform: mockPlatform,
|
|
15
19
|
};
|
|
16
20
|
});
|
|
17
|
-
import { installExternalCli, parseCommand } from './external.js';
|
|
21
|
+
import { formatExternalCliLabel, installExternalCli, parseCommand } from './external.js';
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
23
|
describe('parseCommand', () => {
|
|
19
24
|
it('splits binaries and quoted arguments without invoking a shell', () => {
|
|
20
25
|
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
|
|
@@ -29,6 +34,24 @@ describe('parseCommand', () => {
|
|
|
29
34
|
expect(() => parseCommand('brew install $(whoami)')).toThrow('Install command contains unsafe shell operators');
|
|
30
35
|
expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow('Install command contains unsafe shell operators');
|
|
31
36
|
});
|
|
37
|
+
it('keeps built-in install commands compatible with the shell-free parser', () => {
|
|
38
|
+
const raw = fs.readFileSync(path.join(__dirname, 'external-clis.yaml'), 'utf8');
|
|
39
|
+
const entries = (yaml.load(raw) || []);
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
for (const command of Object.values(entry.install ?? {})) {
|
|
42
|
+
if (command)
|
|
43
|
+
expect(() => parseCommand(command)).not.toThrow();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('formatExternalCliLabel', () => {
|
|
49
|
+
it('shows the package name when the executable name differs', () => {
|
|
50
|
+
expect(formatExternalCliLabel({ name: 'wx', binary: 'wx', package: 'wx-cli' })).toBe('wx(wx-cli)');
|
|
51
|
+
});
|
|
52
|
+
it('keeps the label compact when package and name match', () => {
|
|
53
|
+
expect(formatExternalCliLabel({ name: 'docker', binary: 'docker', package: 'docker' })).toBe('docker');
|
|
54
|
+
});
|
|
32
55
|
});
|
|
33
56
|
describe('installExternalCli', () => {
|
|
34
57
|
const cli = {
|