@jackwener/opencli 1.5.3 → 1.5.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/dist/daemon.js CHANGED
@@ -90,7 +90,20 @@ async function handleRequest(req, res) {
90
90
  res.end();
91
91
  return;
92
92
  }
93
- // Require custom header on all HTTP requests. Browsers cannot attach
93
+ const url = req.url ?? '/';
94
+ const pathname = url.split('?')[0];
95
+ // Health-check endpoint — no X-OpenCLI header required.
96
+ // Used by the extension to silently probe daemon reachability before
97
+ // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
98
+ // Security note: this endpoint is reachable by any client that passes the
99
+ // origin check above (chrome-extension:// or no Origin header, e.g. curl).
100
+ // Timing side-channels can reveal daemon presence to local processes, which
101
+ // is an accepted risk given the daemon is loopback-only and short-lived.
102
+ if (req.method === 'GET' && pathname === '/ping') {
103
+ jsonResponse(res, 200, { ok: true });
104
+ return;
105
+ }
106
+ // Require custom header on all other HTTP requests. Browsers cannot attach
94
107
  // custom headers in "simple" requests, and our preflight returns no
95
108
  // Access-Control-Allow-Headers, so scripted fetch() from web pages is
96
109
  // blocked even if Origin check is somehow bypassed.
@@ -98,8 +111,6 @@ async function handleRequest(req, res) {
98
111
  jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
99
112
  return;
100
113
  }
101
- const url = req.url ?? '/';
102
- const pathname = url.split('?')[0];
103
114
  if (req.method === 'GET' && pathname === '/status') {
104
115
  jsonResponse(res, 200, {
105
116
  ok: true,
@@ -21,3 +21,19 @@
21
21
  tags: [docker, containers, devops]
22
22
  install:
23
23
  mac: "brew install --cask docker"
24
+
25
+ - name: lark-cli
26
+ binary: lark-cli
27
+ description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
28
+ homepage: "https://github.com/larksuite/cli"
29
+ tags: [lark, feishu, collaboration, productivity, ai-agent]
30
+ install:
31
+ default: "npm install -g @larksuite/cli"
32
+
33
+ - name: vercel
34
+ binary: vercel
35
+ description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
36
+ homepage: "https://vercel.com/docs/cli"
37
+ tags: [vercel, deployment, serverless, frontend, devops]
38
+ install:
39
+ default: "npm install -g vercel"
@@ -44,6 +44,11 @@ export function formatArgSummary(args) {
44
44
  })
45
45
  .join(' ');
46
46
  }
47
+ function summarizeChoices(choices) {
48
+ if (choices.length <= 4)
49
+ return choices.join(', ');
50
+ return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
51
+ }
47
52
  /** Generate the --help appendix showing registry metadata not exposed by Commander. */
48
53
  export function formatRegistryHelpText(cmd) {
49
54
  const lines = [];
@@ -51,7 +56,7 @@ export function formatRegistryHelpText(cmd) {
51
56
  for (const a of choicesArgs) {
52
57
  const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
53
58
  const def = a.default != null ? ` (default: ${a.default})` : '';
54
- lines.push(` ${prefix}: ${a.choices.join(', ')}${def}`);
59
+ lines.push(` ${prefix}: ${summarizeChoices(a.choices)}${def}`);
55
60
  }
56
61
  const meta = [];
57
62
  meta.push(`Strategy: ${strategyLabel(cmd)}`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Strategy } from './registry.js';
3
+ import { formatRegistryHelpText } from './serialization.js';
4
+ describe('formatRegistryHelpText', () => {
5
+ it('summarizes long choices lists so help text stays readable', () => {
6
+ const cmd = {
7
+ site: 'demo',
8
+ name: 'dynamic',
9
+ description: 'Demo command',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ {
14
+ name: 'field',
15
+ help: 'Field to use',
16
+ choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
17
+ },
18
+ ],
19
+ columns: ['field'],
20
+ };
21
+ expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
22
+ });
23
+ });
@@ -1,6 +1,7 @@
1
1
  const DAEMON_PORT = 19825;
2
2
  const DAEMON_HOST = "localhost";
3
3
  const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
4
+ const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
4
5
  const WS_RECONNECT_BASE_DELAY = 2e3;
5
6
  const WS_RECONNECT_MAX_DELAY = 6e4;
6
7
 
@@ -149,8 +150,14 @@ console.error = (...args) => {
149
150
  _origError(...args);
150
151
  forwardLog("error", args);
151
152
  };
152
- function connect() {
153
+ async function connect() {
153
154
  if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
155
+ try {
156
+ const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
157
+ if (!res.ok) return;
158
+ } catch {
159
+ return;
160
+ }
154
161
  try {
155
162
  ws = new WebSocket(DAEMON_WS_URL);
156
163
  } catch {
@@ -192,7 +199,7 @@ function scheduleReconnect() {
192
199
  const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
193
200
  reconnectTimer = setTimeout(() => {
194
201
  reconnectTimer = null;
195
- connect();
202
+ void connect();
196
203
  }, delay);
197
204
  }
198
205
  const automationSessions = /* @__PURE__ */ new Map();
@@ -260,7 +267,7 @@ function initialize() {
260
267
  initialized = true;
261
268
  chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
262
269
  registerListeners();
263
- connect();
270
+ void connect();
264
271
  console.log("[opencli] OpenCLI extension initialized");
265
272
  }
266
273
  chrome.runtime.onInstalled.addListener(() => {
@@ -270,7 +277,7 @@ chrome.runtime.onStartup.addListener(() => {
270
277
  initialize();
271
278
  });
272
279
  chrome.alarms.onAlarm.addListener((alarm) => {
273
- if (alarm.name === "keepalive") connect();
280
+ if (alarm.name === "keepalive") void connect();
274
281
  });
275
282
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
276
283
  if (msg?.type === "getStatus") {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCLI",
4
- "version": "1.5.3",
4
+ "version": "1.5.4",
5
5
  "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -35,4 +35,4 @@
35
35
  "extension_pages": "script-src 'self'; object-src 'self'"
36
36
  },
37
37
  "homepage_url": "https://github.com/jackwener/opencli"
38
- }
38
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "1.5.3",
9
+ "version": "1.5.4",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Command, Result } from './protocol';
9
- import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
9
+ import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
10
10
  import * as executor from './cdp';
11
11
 
12
12
  let ws: WebSocket | null = null;
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
34
34
 
35
35
  // ─── WebSocket connection ────────────────────────────────────────────
36
36
 
37
- function connect(): void {
37
+ /**
38
+ * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
39
+ * connection. fetch() failures are silently catchable; new WebSocket() is not
40
+ * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
41
+ * JS handler can intercept it. By keeping the probe inside connect() every
42
+ * call site remains unchanged and the guard can never be accidentally skipped.
43
+ */
44
+ async function connect(): Promise<void> {
38
45
  if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
39
46
 
47
+ try {
48
+ const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
49
+ if (!res.ok) return; // unexpected response — not our daemon
50
+ } catch {
51
+ return; // daemon not running — skip WebSocket to avoid console noise
52
+ }
53
+
40
54
  try {
41
55
  ws = new WebSocket(DAEMON_WS_URL);
42
56
  } catch {
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
90
104
  const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
91
105
  reconnectTimer = setTimeout(() => {
92
106
  reconnectTimer = null;
93
- connect();
107
+ void connect();
94
108
  }, delay);
95
109
  }
96
110
 
@@ -187,7 +201,7 @@ function initialize(): void {
187
201
  initialized = true;
188
202
  chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
189
203
  executor.registerListeners();
190
- connect();
204
+ void connect();
191
205
  console.log('[opencli] OpenCLI extension initialized');
192
206
  }
193
207
 
@@ -200,7 +214,7 @@ chrome.runtime.onStartup.addListener(() => {
200
214
  });
201
215
 
202
216
  chrome.alarms.onAlarm.addListener((alarm) => {
203
- if (alarm.name === 'keepalive') connect();
217
+ if (alarm.name === 'keepalive') void connect();
204
218
  });
205
219
 
206
220
  // ─── Popup status API ───────────────────────────────────────────────
@@ -49,7 +49,8 @@ export interface Result {
49
49
  export const DAEMON_PORT = 19825;
50
50
  export const DAEMON_HOST = 'localhost';
51
51
  export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
52
- export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
52
+ /** Lightweight health-check endpoint — probed before each WebSocket attempt. */
53
+ export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
53
54
 
54
55
  /** Base reconnect delay for extension WebSocket (ms) */
55
56
  export const WS_RECONNECT_BASE_DELAY = 2000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -2,69 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
6
-
7
- describe('parseTsArgsBlock', () => {
8
- it('keeps args with nested choices arrays', () => {
9
- const args = parseTsArgsBlock(`
10
- {
11
- name: 'period',
12
- type: 'string',
13
- default: 'seven',
14
- help: 'Stats period: seven or thirty',
15
- choices: ['seven', 'thirty'],
16
- },
17
- `);
18
-
19
- expect(args).toEqual([
20
- {
21
- name: 'period',
22
- type: 'string',
23
- default: 'seven',
24
- required: false,
25
- positional: undefined,
26
- help: 'Stats period: seven or thirty',
27
- choices: ['seven', 'thirty'],
28
- },
29
- ]);
30
- });
31
-
32
- it('keeps hyphenated arg names from TS adapters', () => {
33
- const args = parseTsArgsBlock(`
34
- {
35
- name: 'tweet-url',
36
- help: 'Single tweet URL to download',
37
- },
38
- {
39
- name: 'download-images',
40
- type: 'boolean',
41
- default: false,
42
- help: 'Download images locally',
43
- },
44
- `);
45
-
46
- expect(args).toEqual([
47
- {
48
- name: 'tweet-url',
49
- type: 'str',
50
- default: undefined,
51
- required: false,
52
- positional: undefined,
53
- help: 'Single tweet URL to download',
54
- choices: undefined,
55
- },
56
- {
57
- name: 'download-images',
58
- type: 'boolean',
59
- default: false,
60
- required: false,
61
- positional: undefined,
62
- help: 'Download images locally',
63
- choices: undefined,
64
- },
65
- ]);
66
- });
67
- });
5
+ import { cli, getRegistry, Strategy } from './registry.js';
6
+ import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js';
68
7
 
69
8
  describe('manifest helper rules', () => {
70
9
  const tempDirs: string[] = [];
@@ -127,43 +66,133 @@ describe('manifest helper rules', () => {
127
66
  const file = path.join(dir, 'utils.ts');
128
67
  fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
129
68
 
130
- expect(scanTs(file, 'demo')).toBeNull();
69
+ return expect(loadTsManifestEntries(file, 'demo', async () => ({}))).resolves.toEqual([]);
131
70
  });
132
71
 
133
- it('keeps literal domain and navigateBefore for TS adapters', () => {
134
- const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
135
- const entry = scanTs(file, 'xueqiu');
136
-
137
- expect(entry).toMatchObject({
138
- site: 'xueqiu',
139
- name: 'fund-holdings',
140
- domain: 'danjuanfunds.com',
141
- navigateBefore: 'https://danjuanfunds.com/my-money',
142
- type: 'ts',
143
- modulePath: 'xueqiu/fund-holdings.js',
144
- });
72
+ it('builds TS manifest entries from exported runtime commands', async () => {
73
+ const site = `manifest-hydrate-${Date.now()}`;
74
+ const key = `${site}/dynamic`;
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
76
+ tempDirs.push(dir);
77
+ const file = path.join(dir, `${site}.ts`);
78
+ fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'dynamic' });`);
79
+
80
+ const entries = await loadTsManifestEntries(file, site, async () => ({
81
+ command: cli({
82
+ site,
83
+ name: 'dynamic',
84
+ description: 'dynamic command',
85
+ strategy: Strategy.PUBLIC,
86
+ browser: false,
87
+ args: [
88
+ {
89
+ name: 'model',
90
+ required: true,
91
+ positional: true,
92
+ help: 'Choose a model',
93
+ choices: ['auto', 'thinking'],
94
+ default: '30',
95
+ },
96
+ ],
97
+ domain: 'localhost',
98
+ navigateBefore: 'https://example.com/session',
99
+ deprecated: 'legacy command',
100
+ replacedBy: 'opencli demo new',
101
+ }),
102
+ }));
103
+
104
+ expect(entries).toEqual([
105
+ {
106
+ site,
107
+ name: 'dynamic',
108
+ description: 'dynamic command',
109
+ domain: 'localhost',
110
+ strategy: 'public',
111
+ browser: false,
112
+ args: [
113
+ {
114
+ name: 'model',
115
+ type: 'str',
116
+ required: true,
117
+ positional: true,
118
+ help: 'Choose a model',
119
+ choices: ['auto', 'thinking'],
120
+ default: '30',
121
+ },
122
+ ],
123
+ type: 'ts',
124
+ modulePath: `${site}/${site}.js`,
125
+ navigateBefore: 'https://example.com/session',
126
+ deprecated: 'legacy command',
127
+ replacedBy: 'opencli demo new',
128
+ },
129
+ ]);
130
+
131
+ getRegistry().delete(key);
145
132
  });
146
133
 
147
- it('captures deprecated metadata for TS adapters', () => {
134
+ it('falls back to registry delta for side-effect-only cli modules', async () => {
135
+ const site = `manifest-side-effect-${Date.now()}`;
136
+ const key = `${site}/legacy`;
148
137
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
149
138
  tempDirs.push(dir);
150
- const file = path.join(dir, 'legacy.ts');
151
- fs.writeFileSync(file, `
152
- import { cli } from '../../registry.js';
139
+ const file = path.join(dir, `${site}.ts`);
140
+ fs.writeFileSync(file, `cli({ site: '${site}', name: 'legacy' });`);
141
+
142
+ const entries = await loadTsManifestEntries(file, site, async () => {
153
143
  cli({
154
- site: 'demo',
144
+ site,
155
145
  name: 'legacy',
156
146
  description: 'legacy command',
157
147
  deprecated: 'legacy is deprecated',
158
148
  replacedBy: 'opencli demo new',
159
149
  });
160
- `);
161
-
162
- expect(scanTs(file, 'demo')).toMatchObject({
163
- site: 'demo',
164
- name: 'legacy',
165
- deprecated: 'legacy is deprecated',
166
- replacedBy: 'opencli demo new',
150
+ return {};
167
151
  });
152
+
153
+ expect(entries).toEqual([
154
+ {
155
+ site,
156
+ name: 'legacy',
157
+ description: 'legacy command',
158
+ strategy: 'cookie',
159
+ browser: true,
160
+ args: [],
161
+ type: 'ts',
162
+ modulePath: `${site}/${site}.js`,
163
+ deprecated: 'legacy is deprecated',
164
+ replacedBy: 'opencli demo new',
165
+ },
166
+ ]);
167
+
168
+ getRegistry().delete(key);
169
+ });
170
+
171
+ it('keeps every command a module exports instead of guessing by site', async () => {
172
+ const site = `manifest-multi-${Date.now()}`;
173
+ const screenKey = `${site}/screen`;
174
+ const statusKey = `${site}/status`;
175
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
176
+ tempDirs.push(dir);
177
+ const file = path.join(dir, `${site}.ts`);
178
+ fs.writeFileSync(file, `export const screen = cli({ site: '${site}', name: 'screen' });`);
179
+
180
+ const entries = await loadTsManifestEntries(file, site, async () => ({
181
+ screen: cli({
182
+ site,
183
+ name: 'screen',
184
+ description: 'capture screen',
185
+ }),
186
+ status: cli({
187
+ site,
188
+ name: 'status',
189
+ description: 'show status',
190
+ }),
191
+ }));
192
+
193
+ expect(entries.map(entry => entry.name)).toEqual(['screen', 'status']);
194
+
195
+ getRegistry().delete(screenKey);
196
+ getRegistry().delete(statusKey);
168
197
  });
169
198
  });