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