@jackwener/opencli 1.7.2 → 1.7.3
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 +4 -1
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +658 -31
- 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/feed.js +202 -48
- package/clis/boss/utils.js +2 -1
- 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}/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/utils.js +29 -2
- package/clis/douban/utils.test.js +121 -1
- 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/xiaohongshu/comments.js +18 -6
- package/clis/xiaohongshu/comments.test.js +36 -0
- 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 +12 -0
- package/clis/xiaohongshu/download.test.js +30 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note.js +14 -5
- package/clis/xiaohongshu/note.test.js +28 -0
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +19 -0
- package/dist/src/browser/cdp.js +10 -2
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/cli.js +4 -2
- package/dist/src/daemon.js +5 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +51 -2
- 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/logger.d.ts +2 -2
- package/dist/src/logger.js +4 -9
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +2 -0
- 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/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
package/dist/src/daemon.js
CHANGED
|
@@ -27,6 +27,7 @@ const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_P
|
|
|
27
27
|
// ─── State ───────────────────────────────────────────────────────────
|
|
28
28
|
let extensionWs = null;
|
|
29
29
|
let extensionVersion = null;
|
|
30
|
+
let extensionCompatRange = null;
|
|
30
31
|
const pending = new Map();
|
|
31
32
|
const LOG_BUFFER_SIZE = 200;
|
|
32
33
|
const logBuffer = [];
|
|
@@ -111,6 +112,7 @@ async function handleRequest(req, res) {
|
|
|
111
112
|
uptime,
|
|
112
113
|
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
|
|
113
114
|
extensionVersion,
|
|
115
|
+
extensionCompatRange,
|
|
114
116
|
pending: pending.size,
|
|
115
117
|
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
|
116
118
|
port: PORT,
|
|
@@ -188,6 +190,7 @@ wss.on('connection', (ws) => {
|
|
|
188
190
|
log.info('[daemon] Extension connected');
|
|
189
191
|
extensionWs = ws;
|
|
190
192
|
extensionVersion = null; // cleared until hello message arrives
|
|
193
|
+
extensionCompatRange = null;
|
|
191
194
|
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
192
195
|
let missedPongs = 0;
|
|
193
196
|
const heartbeatInterval = setInterval(() => {
|
|
@@ -213,6 +216,7 @@ wss.on('connection', (ws) => {
|
|
|
213
216
|
// Handle hello message from extension (version handshake)
|
|
214
217
|
if (msg.type === 'hello') {
|
|
215
218
|
extensionVersion = typeof msg.version === 'string' ? msg.version : null;
|
|
219
|
+
extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
|
|
216
220
|
return;
|
|
217
221
|
}
|
|
218
222
|
// Handle log messages from extension
|
|
@@ -244,6 +248,7 @@ wss.on('connection', (ws) => {
|
|
|
244
248
|
if (extensionWs === ws) {
|
|
245
249
|
extensionWs = null;
|
|
246
250
|
extensionVersion = null;
|
|
251
|
+
extensionCompatRange = null;
|
|
247
252
|
// Reject all pending requests since the extension is gone
|
|
248
253
|
for (const [id, p] of pending) {
|
|
249
254
|
clearTimeout(p.timer);
|
package/dist/src/doctor.d.ts
CHANGED
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
|
*/
|
|
@@ -80,7 +110,16 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
80
110
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
81
111
|
}
|
|
82
112
|
const extensionVersion = health.status?.extensionVersion;
|
|
83
|
-
|
|
113
|
+
const extensionCompatRange = health.status?.extensionCompatRange;
|
|
114
|
+
if (extensionVersion && opts.cliVersion && extensionCompatRange) {
|
|
115
|
+
if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
|
|
116
|
+
issues.push(`CLI version incompatible with extension: extension v${extensionVersion} requires CLI ${extensionCompatRange}, but CLI is v${opts.cliVersion}\n` +
|
|
117
|
+
' Update the CLI: npm install -g @jackwener/opencli\n' +
|
|
118
|
+
' Or download a compatible extension from: https://github.com/jackwener/opencli/releases');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (extensionVersion && opts.cliVersion) {
|
|
122
|
+
// Fallback for older extensions that don't send compatRange
|
|
84
123
|
const extMajor = extensionVersion.split('.')[0];
|
|
85
124
|
const cliMajor = opts.cliVersion.split('.')[0];
|
|
86
125
|
if (extMajor !== cliMajor) {
|
|
@@ -88,6 +127,12 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
88
127
|
' Download the latest extension from: https://github.com/jackwener/opencli/releases');
|
|
89
128
|
}
|
|
90
129
|
}
|
|
130
|
+
// Extension update check (from cached background fetch)
|
|
131
|
+
const latestExtensionVersion = getCachedLatestExtensionVersion();
|
|
132
|
+
if (extensionVersion && latestExtensionVersion && isNewerVersion(latestExtensionVersion, extensionVersion)) {
|
|
133
|
+
issues.push(`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
|
|
134
|
+
' Download from: https://github.com/jackwener/opencli/releases');
|
|
135
|
+
}
|
|
91
136
|
return {
|
|
92
137
|
cliVersion: opts.cliVersion,
|
|
93
138
|
daemonRunning,
|
|
@@ -95,6 +140,7 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
95
140
|
extensionConnected,
|
|
96
141
|
extensionFlaky,
|
|
97
142
|
extensionVersion,
|
|
143
|
+
latestExtensionVersion,
|
|
98
144
|
connectivity,
|
|
99
145
|
sessions,
|
|
100
146
|
issues,
|
|
@@ -114,7 +160,10 @@ export function renderBrowserDoctorReport(report) {
|
|
|
114
160
|
const extIcon = report.extensionFlaky
|
|
115
161
|
? styleText('yellow', '[WARN]')
|
|
116
162
|
: report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
|
|
117
|
-
const
|
|
163
|
+
const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
|
|
164
|
+
? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
|
|
165
|
+
: '';
|
|
166
|
+
const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint : '';
|
|
118
167
|
const extLabel = report.extensionFlaky
|
|
119
168
|
? 'unstable (connected during live check, then disconnected)'
|
|
120
169
|
: report.extensionConnected ? 'connected' : 'not connected';
|
|
@@ -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 = {
|
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, OPENCLI_VERBOSE, or DEBUG=opencli is set) */
|
|
19
19
|
verbose(msg: string): void;
|
|
20
|
-
/**
|
|
20
|
+
/** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
|
|
21
21
|
debug(msg: string): void;
|
|
22
22
|
/** Step-style debug (for pipeline steps, etc.) */
|
|
23
23
|
step(stepNum: number, total: number, op: string, preview?: string): void;
|
package/dist/src/logger.js
CHANGED
|
@@ -6,10 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { styleText } from 'node:util';
|
|
8
8
|
function isVerbose() {
|
|
9
|
-
return !!process.env.OPENCLI_VERBOSE;
|
|
10
|
-
}
|
|
11
|
-
function isDebug() {
|
|
12
|
-
return !!process.env.DEBUG?.includes('opencli');
|
|
9
|
+
return !!process.env.OPENCLI_VERBOSE || !!process.env.DEBUG?.includes('opencli');
|
|
13
10
|
}
|
|
14
11
|
export const log = {
|
|
15
12
|
/** Informational message (always shown) */
|
|
@@ -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, OPENCLI_VERBOSE, or DEBUG=opencli 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
|
+
/** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
|
|
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/registry.js
CHANGED
|
@@ -77,10 +77,9 @@ export function registerCommand(cmd) {
|
|
|
77
77
|
const normalized = normalizeCommand(cmd);
|
|
78
78
|
const canonicalKey = fullName(normalized);
|
|
79
79
|
const existing = _registry.get(canonicalKey);
|
|
80
|
-
if (existing) {
|
|
81
|
-
for (const
|
|
82
|
-
|
|
83
|
-
_registry.delete(key);
|
|
80
|
+
if (existing?.aliases) {
|
|
81
|
+
for (const alias of existing.aliases) {
|
|
82
|
+
_registry.delete(`${existing.site}/${alias}`);
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
const aliases = normalizeAliases(normalized.aliases, normalized.name);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface IPage {
|
|
|
44
44
|
settleMs?: number;
|
|
45
45
|
}): Promise<void>;
|
|
46
46
|
evaluate(js: string): Promise<any>;
|
|
47
|
+
/** Safely evaluate JS with pre-serialized arguments — prevents injection. */
|
|
48
|
+
evaluateWithArgs?(js: string, args: Record<string, unknown>): Promise<any>;
|
|
47
49
|
getCookies(opts?: {
|
|
48
50
|
domain?: string;
|
|
49
51
|
url?: string;
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - Notice appears AFTER command output, not before (same as npm/gh/yarn)
|
|
9
9
|
* - Never delays or blocks the CLI command
|
|
10
10
|
*/
|
|
11
|
+
interface GitHubReleaseAsset {
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
interface GitHubRelease {
|
|
15
|
+
tag_name: string;
|
|
16
|
+
assets?: GitHubReleaseAsset[];
|
|
17
|
+
}
|
|
11
18
|
/**
|
|
12
19
|
* Register a process exit hook that prints an update notice if a newer
|
|
13
20
|
* version was found on the last background check.
|
|
@@ -15,8 +22,15 @@
|
|
|
15
22
|
* Skipped during --get-completions to avoid polluting shell completion output.
|
|
16
23
|
*/
|
|
17
24
|
export declare function registerUpdateNoticeOnExit(): void;
|
|
25
|
+
declare function extractLatestExtensionVersionFromReleases(releases: GitHubRelease[]): string | undefined;
|
|
18
26
|
/**
|
|
19
27
|
* Kick off a background fetch to npm registry. Writes to cache for next run.
|
|
20
28
|
* Fully non-blocking — never awaited.
|
|
21
29
|
*/
|
|
22
30
|
export declare function checkForUpdateBackground(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Get the cached latest extension version (if available).
|
|
33
|
+
* Used by `opencli doctor` to report extension updates.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getCachedLatestExtensionVersion(): string | undefined;
|
|
36
|
+
export { extractLatestExtensionVersionFromReleases as _extractLatestExtensionVersionFromReleases, };
|
package/dist/src/update-check.js
CHANGED
|
@@ -17,6 +17,7 @@ const CACHE_DIR = path.join(os.homedir(), '.opencli');
|
|
|
17
17
|
const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json');
|
|
18
18
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
19
19
|
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@jackwener/opencli/latest';
|
|
20
|
+
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jackwener/OpenCLI/releases?per_page=20';
|
|
20
21
|
// Read cache once at module load — shared by both exported functions
|
|
21
22
|
const _cache = (() => {
|
|
22
23
|
try {
|
|
@@ -26,10 +27,13 @@ const _cache = (() => {
|
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
28
29
|
})();
|
|
29
|
-
function writeCache(latestVersion) {
|
|
30
|
+
function writeCache(latestVersion, latestExtensionVersion) {
|
|
30
31
|
try {
|
|
31
32
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
32
|
-
|
|
33
|
+
const data = { lastCheck: Date.now(), latestVersion };
|
|
34
|
+
if (latestExtensionVersion)
|
|
35
|
+
data.latestExtensionVersion = latestExtensionVersion;
|
|
36
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8');
|
|
33
37
|
}
|
|
34
38
|
catch {
|
|
35
39
|
// Best-effort; never fail
|
|
@@ -80,6 +84,38 @@ export function registerUpdateNoticeOnExit() {
|
|
|
80
84
|
}
|
|
81
85
|
});
|
|
82
86
|
}
|
|
87
|
+
function extractLatestExtensionVersionFromReleases(releases) {
|
|
88
|
+
for (const release of releases) {
|
|
89
|
+
for (const asset of release.assets ?? []) {
|
|
90
|
+
const assetMatch = asset.name.match(/^opencli-extension-v(.+)\.zip$/);
|
|
91
|
+
if (assetMatch)
|
|
92
|
+
return assetMatch[1];
|
|
93
|
+
}
|
|
94
|
+
const tagMatch = release.tag_name.match(/^ext-v(.+)$/);
|
|
95
|
+
if (tagMatch)
|
|
96
|
+
return tagMatch[1];
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/** Fetch the latest extension version from GitHub Releases. */
|
|
101
|
+
async function fetchLatestExtensionVersion() {
|
|
102
|
+
try {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
105
|
+
const res = await fetch(GITHUB_RELEASES_URL, {
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
headers: { 'User-Agent': `opencli/${PKG_VERSION}`, Accept: 'application/vnd.github+json' },
|
|
108
|
+
});
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
return undefined;
|
|
112
|
+
const releases = await res.json();
|
|
113
|
+
return extractLatestExtensionVersionFromReleases(releases);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
83
119
|
/**
|
|
84
120
|
* Kick off a background fetch to npm registry. Writes to cache for next run.
|
|
85
121
|
* Fully non-blocking — never awaited.
|
|
@@ -102,7 +138,8 @@ export function checkForUpdateBackground() {
|
|
|
102
138
|
return;
|
|
103
139
|
const data = await res.json();
|
|
104
140
|
if (typeof data.version === 'string') {
|
|
105
|
-
|
|
141
|
+
const extVersion = await fetchLatestExtensionVersion();
|
|
142
|
+
writeCache(data.version, extVersion);
|
|
106
143
|
}
|
|
107
144
|
}
|
|
108
145
|
catch {
|
|
@@ -110,3 +147,11 @@ export function checkForUpdateBackground() {
|
|
|
110
147
|
}
|
|
111
148
|
})();
|
|
112
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Get the cached latest extension version (if available).
|
|
152
|
+
* Used by `opencli doctor` to report extension updates.
|
|
153
|
+
*/
|
|
154
|
+
export function getCachedLatestExtensionVersion() {
|
|
155
|
+
return _cache?.latestExtensionVersion;
|
|
156
|
+
}
|
|
157
|
+
export { extractLatestExtensionVersionFromReleases as _extractLatestExtensionVersionFromReleases, };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { _extractLatestExtensionVersionFromReleases as extractLatestExtensionVersionFromReleases } from './update-check.js';
|
|
3
|
+
describe('extractLatestExtensionVersionFromReleases', () => {
|
|
4
|
+
it('reads the extension version from a versioned asset on a normal CLI release', () => {
|
|
5
|
+
expect(extractLatestExtensionVersionFromReleases([
|
|
6
|
+
{
|
|
7
|
+
tag_name: 'v1.7.3',
|
|
8
|
+
assets: [
|
|
9
|
+
{ name: 'opencli-extension.zip' },
|
|
10
|
+
{ name: 'opencli-extension-v1.0.2.zip' },
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
])).toBe('1.0.2');
|
|
14
|
+
});
|
|
15
|
+
it('falls back to ext-v tags for extension-only releases', () => {
|
|
16
|
+
expect(extractLatestExtensionVersionFromReleases([
|
|
17
|
+
{
|
|
18
|
+
tag_name: 'ext-v1.1.0',
|
|
19
|
+
assets: [{ name: 'opencli-extension.zip' }],
|
|
20
|
+
},
|
|
21
|
+
])).toBe('1.1.0');
|
|
22
|
+
});
|
|
23
|
+
it('returns undefined when no extension version source exists', () => {
|
|
24
|
+
expect(extractLatestExtensionVersionFromReleases([
|
|
25
|
+
{
|
|
26
|
+
tag_name: 'v1.7.3',
|
|
27
|
+
assets: [{ name: 'opencli-extension.zip' }],
|
|
28
|
+
},
|
|
29
|
+
])).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
|
-
import { join, resolve, dirname } from 'node:path';
|
|
24
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
|
|
27
27
|
const OPENCLI_DIR = join(homedir(), '.opencli');
|
|
@@ -82,8 +82,11 @@ function walkFiles(dir, prefix = '') {
|
|
|
82
82
|
* Remove empty parent directories up to (but not including) stopAt.
|
|
83
83
|
*/
|
|
84
84
|
function pruneEmptyDirs(filePath, stopAt) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
const boundary = resolve(stopAt);
|
|
86
|
+
let dir = resolve(dirname(filePath));
|
|
87
|
+
while (dir !== boundary) {
|
|
88
|
+
const rel = relative(boundary, dir);
|
|
89
|
+
if (!rel || rel.startsWith('..')) break;
|
|
87
90
|
try {
|
|
88
91
|
const entries = readdirSync(dir);
|
|
89
92
|
if (entries.length > 0) break;
|
|
@@ -113,7 +116,15 @@ export function fetchAdapters() {
|
|
|
113
116
|
|
|
114
117
|
const newOfficialFiles = new Set(walkFiles(BUILTIN_CLIS));
|
|
115
118
|
const oldOfficialFiles = new Set(oldManifest?.files ?? []);
|
|
116
|
-
const
|
|
119
|
+
const rawHashes = oldManifest?.hashes;
|
|
120
|
+
// Guard against corrupted manifest: if hashes is a non-object type (string, number,
|
|
121
|
+
// array), skip sync to avoid false-positive "changed" detection that deletes overrides.
|
|
122
|
+
// null/undefined are treated as empty (old manifests may lack the field).
|
|
123
|
+
if (rawHashes != null && (typeof rawHashes !== 'object' || Array.isArray(rawHashes))) {
|
|
124
|
+
log('Warning: adapter-manifest.json has corrupted hashes — skipping sync. Will fix on next run.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const oldHashes = rawHashes ?? {};
|
|
117
128
|
mkdirSync(USER_CLIS_DIR, { recursive: true });
|
|
118
129
|
|
|
119
130
|
// 1. Compute new hashes and detect which sites have changes
|
|
@@ -175,6 +186,24 @@ export function fetchAdapters() {
|
|
|
175
186
|
}
|
|
176
187
|
if (tsCleaned > 0) log(`Cleaned up ${tsCleaned} stale .ts adapter files`);
|
|
177
188
|
|
|
189
|
+
// 3b. Clean up stale .yaml/.yml adapter files left by older versions (pre-1.7.0)
|
|
190
|
+
// Older versions shipped adapters as YAML; current versions use .js only.
|
|
191
|
+
// These cause "Ignoring YAML adapter" warnings on every run (issue #953).
|
|
192
|
+
let yamlCleaned = 0;
|
|
193
|
+
for (const relPath of walkFiles(USER_CLIS_DIR)) {
|
|
194
|
+
if (relPath.endsWith('.yaml') || relPath.endsWith('.yml')) {
|
|
195
|
+
const jsCounterpart = relPath.replace(/\.ya?ml$/, '.js');
|
|
196
|
+
if (newOfficialFiles.has(jsCounterpart)) {
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(join(USER_CLIS_DIR, relPath));
|
|
199
|
+
pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
|
|
200
|
+
yamlCleaned++;
|
|
201
|
+
} catch { /* ignore */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (yamlCleaned > 0) log(`Cleaned up ${yamlCleaned} stale .yaml adapter files`);
|
|
206
|
+
|
|
178
207
|
// 4. Clean up legacy compat shim files from ~/.opencli/
|
|
179
208
|
// These were created by an older approach that placed re-export shims directly
|
|
180
209
|
// in ~/.opencli/ (e.g., registry.js, errors.js, browser/). The current approach
|
|
@@ -245,15 +274,13 @@ export function fetchAdapters() {
|
|
|
245
274
|
}, null, 2));
|
|
246
275
|
|
|
247
276
|
log(`Synced adapters: ${cleared} local override(s) cleared` +
|
|
248
|
-
(tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : '')
|
|
277
|
+
(tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : '') +
|
|
278
|
+
(yamlCleaned > 0 ? `, ${yamlCleaned} stale .yaml files removed` : ''));
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
function main() {
|
|
252
282
|
// Skip in CI
|
|
253
283
|
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return;
|
|
254
|
-
// Allow opt-out
|
|
255
|
-
if (process.env.OPENCLI_SKIP_FETCH === '1') return;
|
|
256
|
-
|
|
257
284
|
// Only run on global install, explicit trigger, or first-run fallback
|
|
258
285
|
const isGlobal = process.env.npm_config_global === 'true';
|
|
259
286
|
const isExplicit = process.env.OPENCLI_FETCH === '1';
|
|
File without changes
|