@jackwener/opencli 1.5.2 → 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/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
- package/.github/workflows/ci.yml +6 -7
- package/README.md +89 -235
- package/dist/browser/cdp.js +20 -1
- package/dist/browser/daemon-client.js +3 -2
- package/dist/browser/dom-helpers.d.ts +11 -0
- package/dist/browser/dom-helpers.js +42 -0
- package/dist/browser/dom-helpers.test.d.ts +1 -0
- package/dist/browser/dom-helpers.test.js +92 -0
- package/dist/browser/index.d.ts +0 -12
- package/dist/browser/index.js +0 -13
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +14 -1
- package/dist/browser.test.js +15 -11
- package/dist/build-manifest.d.ts +2 -3
- package/dist/build-manifest.js +75 -170
- package/dist/build-manifest.test.js +113 -88
- package/dist/cli-manifest.json +1199 -1106
- package/dist/clis/36kr/hot.js +1 -1
- package/dist/clis/36kr/search.js +1 -1
- package/dist/clis/_shared/common.d.ts +8 -0
- package/dist/clis/_shared/common.js +10 -0
- package/dist/clis/bloomberg/news.js +1 -1
- package/dist/clis/douban/utils.js +3 -6
- package/dist/clis/medium/utils.js +1 -1
- package/dist/clis/producthunt/browse.js +1 -1
- package/dist/clis/producthunt/hot.js +1 -1
- package/dist/clis/sinablog/utils.js +6 -7
- package/dist/clis/substack/utils.js +2 -2
- package/dist/clis/twitter/block.js +1 -1
- package/dist/clis/twitter/bookmark.js +1 -1
- package/dist/clis/twitter/delete.js +1 -1
- package/dist/clis/twitter/follow.js +1 -1
- package/dist/clis/twitter/followers.js +2 -2
- package/dist/clis/twitter/following.js +2 -2
- package/dist/clis/twitter/hide-reply.js +1 -1
- package/dist/clis/twitter/like.js +1 -1
- package/dist/clis/twitter/notifications.js +1 -1
- package/dist/clis/twitter/profile.js +1 -1
- package/dist/clis/twitter/reply-dm.js +1 -1
- package/dist/clis/twitter/reply.js +1 -1
- package/dist/clis/twitter/search.js +1 -1
- package/dist/clis/twitter/unblock.js +1 -1
- package/dist/clis/twitter/unbookmark.js +1 -1
- package/dist/clis/twitter/unfollow.js +1 -1
- package/dist/clis/xiaohongshu/comments.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +1 -0
- package/dist/clis/xiaohongshu/search.test.js +1 -0
- package/dist/daemon.js +14 -3
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -2
- package/dist/external-clis.yaml +16 -0
- package/dist/main.js +2 -0
- package/dist/node-network.d.ts +10 -0
- package/dist/node-network.js +174 -0
- package/dist/node-network.test.d.ts +1 -0
- package/dist/node-network.test.js +55 -0
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.test.js +1 -0
- package/dist/pipeline/steps/intercept.js +4 -5
- package/dist/serialization.js +6 -1
- package/dist/serialization.test.d.ts +1 -0
- package/dist/serialization.test.js +23 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
- package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
- package/extension/dist/background.js +12 -5
- package/extension/manifest.json +2 -2
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +20 -6
- package/extension/src/protocol.ts +2 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -15
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +16 -0
- package/src/browser.test.ts +16 -12
- package/src/build-manifest.test.ts +117 -88
- package/src/build-manifest.ts +81 -180
- package/src/clis/36kr/hot.ts +1 -1
- package/src/clis/36kr/search.ts +1 -1
- package/src/clis/_shared/common.ts +11 -0
- package/src/clis/bloomberg/news.ts +1 -1
- package/src/clis/douban/utils.ts +3 -7
- package/src/clis/medium/utils.ts +1 -1
- package/src/clis/producthunt/browse.ts +1 -1
- package/src/clis/producthunt/hot.ts +1 -1
- package/src/clis/sinablog/utils.ts +6 -7
- package/src/clis/substack/utils.ts +2 -2
- package/src/clis/twitter/block.ts +1 -1
- package/src/clis/twitter/bookmark.ts +1 -1
- package/src/clis/twitter/delete.ts +1 -1
- package/src/clis/twitter/follow.ts +1 -1
- package/src/clis/twitter/followers.ts +2 -2
- package/src/clis/twitter/following.ts +2 -2
- package/src/clis/twitter/hide-reply.ts +1 -1
- package/src/clis/twitter/like.ts +1 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/clis/twitter/profile.ts +1 -1
- package/src/clis/twitter/reply-dm.ts +1 -1
- package/src/clis/twitter/reply.ts +1 -1
- package/src/clis/twitter/search.ts +1 -1
- package/src/clis/twitter/unblock.ts +1 -1
- package/src/clis/twitter/unbookmark.ts +1 -1
- package/src/clis/twitter/unfollow.ts +1 -1
- package/src/clis/xiaohongshu/comments.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
- package/src/clis/xiaohongshu/publish.test.ts +1 -0
- package/src/clis/xiaohongshu/search.test.ts +1 -0
- package/src/daemon.ts +16 -4
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -2
- package/src/external-clis.yaml +16 -0
- package/src/main.ts +3 -0
- package/src/node-network.test.ts +93 -0
- package/src/node-network.ts +213 -0
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.test.ts +1 -0
- package/src/pipeline/steps/intercept.ts +4 -5
- package/src/serialization.test.ts +26 -0
- package/src/serialization.ts +6 -1
- package/src/types.ts +2 -0
- package/src/utils.ts +5 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
|
|
3
|
+
describe('waitForCaptureJs', () => {
|
|
4
|
+
it('returns a non-empty string', () => {
|
|
5
|
+
const code = waitForCaptureJs(1000);
|
|
6
|
+
expect(typeof code).toBe('string');
|
|
7
|
+
expect(code.length).toBeGreaterThan(0);
|
|
8
|
+
expect(code).toContain('__opencli_xhr');
|
|
9
|
+
expect(code).toContain('resolve');
|
|
10
|
+
expect(code).toContain('reject');
|
|
11
|
+
});
|
|
12
|
+
it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
|
|
13
|
+
const g = globalThis;
|
|
14
|
+
g.__opencli_xhr = [];
|
|
15
|
+
g.window = g; // stub window for Node eval
|
|
16
|
+
const code = waitForCaptureJs(1000);
|
|
17
|
+
const promise = eval(code);
|
|
18
|
+
g.__opencli_xhr.push({ data: 'test' });
|
|
19
|
+
await expect(promise).resolves.toBe('captured');
|
|
20
|
+
delete g.__opencli_xhr;
|
|
21
|
+
delete g.window;
|
|
22
|
+
});
|
|
23
|
+
it('rejects when __opencli_xhr stays empty past deadline', async () => {
|
|
24
|
+
const g = globalThis;
|
|
25
|
+
g.__opencli_xhr = [];
|
|
26
|
+
g.window = g;
|
|
27
|
+
const code = waitForCaptureJs(50); // 50ms timeout
|
|
28
|
+
const promise = eval(code);
|
|
29
|
+
await expect(promise).rejects.toThrow('No network capture within 0.05s');
|
|
30
|
+
delete g.__opencli_xhr;
|
|
31
|
+
delete g.window;
|
|
32
|
+
});
|
|
33
|
+
it('resolves immediately when __opencli_xhr already has data', async () => {
|
|
34
|
+
const g = globalThis;
|
|
35
|
+
g.__opencli_xhr = [{ data: 'already here' }];
|
|
36
|
+
g.window = g;
|
|
37
|
+
const code = waitForCaptureJs(1000);
|
|
38
|
+
await expect(eval(code)).resolves.toBe('captured');
|
|
39
|
+
delete g.__opencli_xhr;
|
|
40
|
+
delete g.window;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('waitForSelectorJs', () => {
|
|
44
|
+
it('returns a non-empty string', () => {
|
|
45
|
+
const code = waitForSelectorJs('#app', 1000);
|
|
46
|
+
expect(typeof code).toBe('string');
|
|
47
|
+
expect(code).toContain('#app');
|
|
48
|
+
expect(code).toContain('querySelector');
|
|
49
|
+
expect(code).toContain('MutationObserver');
|
|
50
|
+
});
|
|
51
|
+
it('resolves "found" immediately when selector already present', async () => {
|
|
52
|
+
const g = globalThis;
|
|
53
|
+
const fakeEl = { tagName: 'DIV' };
|
|
54
|
+
g.document = { querySelector: (_) => fakeEl };
|
|
55
|
+
const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
|
|
56
|
+
await expect(eval(code)).resolves.toBe('found');
|
|
57
|
+
delete g.document;
|
|
58
|
+
});
|
|
59
|
+
it('resolves "found" when selector appears after DOM mutation', async () => {
|
|
60
|
+
const g = globalThis;
|
|
61
|
+
let mutationCallback;
|
|
62
|
+
g.MutationObserver = class {
|
|
63
|
+
constructor(cb) { mutationCallback = cb; }
|
|
64
|
+
observe() { }
|
|
65
|
+
disconnect() { }
|
|
66
|
+
};
|
|
67
|
+
let calls = 0;
|
|
68
|
+
g.document = {
|
|
69
|
+
querySelector: (_) => (calls++ > 0 ? { tagName: 'DIV' } : null),
|
|
70
|
+
body: {},
|
|
71
|
+
};
|
|
72
|
+
const code = waitForSelectorJs('#app', 1000);
|
|
73
|
+
const promise = eval(code);
|
|
74
|
+
mutationCallback(); // simulate DOM mutation
|
|
75
|
+
await expect(promise).resolves.toBe('found');
|
|
76
|
+
delete g.document;
|
|
77
|
+
delete g.MutationObserver;
|
|
78
|
+
});
|
|
79
|
+
it('rejects when selector never appears within timeout', async () => {
|
|
80
|
+
const g = globalThis;
|
|
81
|
+
g.MutationObserver = class {
|
|
82
|
+
constructor(_cb) { }
|
|
83
|
+
observe() { }
|
|
84
|
+
disconnect() { }
|
|
85
|
+
};
|
|
86
|
+
g.document = { querySelector: (_) => null, body: {} };
|
|
87
|
+
const code = waitForSelectorJs('#missing', 50);
|
|
88
|
+
await expect(eval(code)).rejects.toThrow('Selector not found: #missing');
|
|
89
|
+
delete g.document;
|
|
90
|
+
delete g.MutationObserver;
|
|
91
|
+
});
|
|
92
|
+
});
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -11,15 +11,3 @@ export { isDaemonRunning } from './daemon-client.js';
|
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
13
|
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
|
14
|
-
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
15
|
-
import { isRetryableSettleError } from './page.js';
|
|
16
|
-
import { withTimeoutMs } from '../runtime.js';
|
|
17
|
-
export declare const __test__: {
|
|
18
|
-
extractTabEntries: typeof extractTabEntries;
|
|
19
|
-
diffTabIndexes: typeof diffTabIndexes;
|
|
20
|
-
appendLimited: typeof appendLimited;
|
|
21
|
-
withTimeoutMs: typeof withTimeoutMs;
|
|
22
|
-
selectCDPTarget: (targets: import("./cdp.js").CDPTarget[]) => import("./cdp.js").CDPTarget | undefined;
|
|
23
|
-
scoreCDPTarget: (target: import("./cdp.js").CDPTarget, preferredPattern?: RegExp) => number;
|
|
24
|
-
isRetryableSettleError: typeof isRetryableSettleError;
|
|
25
|
-
};
|
package/dist/browser/index.js
CHANGED
|
@@ -10,16 +10,3 @@ export { CDPBridge } from './cdp.js';
|
|
|
10
10
|
export { isDaemonRunning } from './daemon-client.js';
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
|
-
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
|
-
import { __test__ as cdpTest } from './cdp.js';
|
|
15
|
-
import { isRetryableSettleError } from './page.js';
|
|
16
|
-
import { withTimeoutMs } from '../runtime.js';
|
|
17
|
-
export const __test__ = {
|
|
18
|
-
extractTabEntries,
|
|
19
|
-
diffTabIndexes,
|
|
20
|
-
appendLimited,
|
|
21
|
-
withTimeoutMs,
|
|
22
|
-
selectCDPTarget: cdpTest.selectCDPTarget,
|
|
23
|
-
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
|
24
|
-
isRetryableSettleError,
|
|
25
|
-
};
|
package/dist/browser/mcp.js
CHANGED
|
@@ -82,10 +82,11 @@ export class BrowserBridge {
|
|
|
82
82
|
env: { ...process.env },
|
|
83
83
|
});
|
|
84
84
|
this._daemonProc.unref();
|
|
85
|
-
// Wait for daemon to be ready AND extension to connect
|
|
85
|
+
// Wait for daemon to be ready AND extension to connect (exponential backoff)
|
|
86
|
+
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
|
|
86
87
|
const deadline = Date.now() + timeoutMs;
|
|
87
|
-
|
|
88
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
88
|
+
for (let i = 0; Date.now() < deadline; i++) {
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
|
|
89
90
|
if (await isExtensionConnected())
|
|
90
91
|
return;
|
|
91
92
|
}
|
package/dist/browser/page.d.ts
CHANGED
package/dist/browser/page.js
CHANGED
|
@@ -15,7 +15,7 @@ import { wrapForEval } from './utils.js';
|
|
|
15
15
|
import { saveBase64ToFile } from '../utils.js';
|
|
16
16
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
17
17
|
import { generateStealthJs } from './stealth.js';
|
|
18
|
-
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
18
|
+
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
19
19
|
export function isRetryableSettleError(err) {
|
|
20
20
|
const message = err instanceof Error ? err.message : String(err);
|
|
21
21
|
return message.includes('Inspected target navigated or closed')
|
|
@@ -219,6 +219,12 @@ export class Page {
|
|
|
219
219
|
await new Promise(resolve => setTimeout(resolve, options.time * 1000));
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
|
+
if (options.selector) {
|
|
223
|
+
const timeout = (options.timeout ?? 10) * 1000;
|
|
224
|
+
const code = waitForSelectorJs(options.selector, timeout);
|
|
225
|
+
await sendCommand('exec', { code, ...this._cmdOpts() });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
222
228
|
if (options.text) {
|
|
223
229
|
const timeout = (options.timeout ?? 30) * 1000;
|
|
224
230
|
const code = waitForTextJs(options.text, timeout);
|
|
@@ -302,5 +308,12 @@ export class Page {
|
|
|
302
308
|
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
303
309
|
return Array.isArray(result) ? result : [];
|
|
304
310
|
}
|
|
311
|
+
async waitForCapture(timeout = 10) {
|
|
312
|
+
const maxMs = timeout * 1000;
|
|
313
|
+
await sendCommand('exec', {
|
|
314
|
+
code: waitForCaptureJs(maxMs),
|
|
315
|
+
...this._cmdOpts(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
305
318
|
}
|
|
306
319
|
// (End of file)
|
package/dist/browser.test.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { BrowserBridge,
|
|
2
|
+
import { BrowserBridge, generateStealthJs } from './browser/index.js';
|
|
3
|
+
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
|
|
4
|
+
import { withTimeoutMs } from './runtime.js';
|
|
5
|
+
import { __test__ as cdpTest } from './browser/cdp.js';
|
|
6
|
+
import { isRetryableSettleError } from './browser/page.js';
|
|
3
7
|
import * as daemonClient from './browser/daemon-client.js';
|
|
4
8
|
describe('browser helpers', () => {
|
|
5
9
|
it('extracts tab entries from string snapshots', () => {
|
|
6
|
-
const entries =
|
|
10
|
+
const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
|
7
11
|
expect(entries).toEqual([
|
|
8
12
|
{ index: 0, identity: 'https://example.com' },
|
|
9
13
|
{ index: 1, identity: 'Chrome Extension' },
|
|
10
14
|
]);
|
|
11
15
|
});
|
|
12
16
|
it('extracts tab entries from MCP markdown format', () => {
|
|
13
|
-
const entries =
|
|
17
|
+
const entries = extractTabEntries('- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)');
|
|
14
18
|
expect(entries).toEqual([
|
|
15
19
|
{ index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
|
|
16
20
|
{ index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
|
|
17
21
|
]);
|
|
18
22
|
});
|
|
19
23
|
it('closes only tabs that were opened during the session', () => {
|
|
20
|
-
const tabsToClose =
|
|
24
|
+
const tabsToClose = diffTabIndexes(['https://example.com', 'Chrome Extension'], [
|
|
21
25
|
{ index: 0, identity: 'https://example.com' },
|
|
22
26
|
{ index: 1, identity: 'Chrome Extension' },
|
|
23
27
|
{ index: 2, identity: 'https://target.example/page' },
|
|
@@ -26,18 +30,18 @@ describe('browser helpers', () => {
|
|
|
26
30
|
expect(tabsToClose).toEqual([3, 2]);
|
|
27
31
|
});
|
|
28
32
|
it('keeps only the tail of stderr buffers', () => {
|
|
29
|
-
expect(
|
|
33
|
+
expect(appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
30
34
|
});
|
|
31
35
|
it('times out slow promises', async () => {
|
|
32
|
-
await expect(
|
|
36
|
+
await expect(withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
|
|
33
37
|
});
|
|
34
38
|
it('retries settle only for target-invalidated errors', () => {
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
39
|
+
expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
|
|
40
|
+
expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
|
|
41
|
+
expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
|
|
38
42
|
});
|
|
39
43
|
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
|
40
|
-
const target =
|
|
44
|
+
const target = cdpTest.selectCDPTarget([
|
|
41
45
|
{
|
|
42
46
|
type: 'page',
|
|
43
47
|
title: 'DevTools - localhost:9224',
|
|
@@ -61,7 +65,7 @@ describe('browser helpers', () => {
|
|
|
61
65
|
});
|
|
62
66
|
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
|
|
63
67
|
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
|
|
64
|
-
const target =
|
|
68
|
+
const target = cdpTest.selectCDPTarget([
|
|
65
69
|
{
|
|
66
70
|
type: 'app',
|
|
67
71
|
title: 'Cursor',
|
package/dist/build-manifest.d.ts
CHANGED
|
@@ -36,11 +36,10 @@ export interface ManifestEntry {
|
|
|
36
36
|
/** Pre-navigation control — see CliCommand.navigateBefore */
|
|
37
37
|
navigateBefore?: boolean | string;
|
|
38
38
|
}
|
|
39
|
-
export declare function
|
|
40
|
-
export declare function scanTs(filePath: string, site: string): ManifestEntry | null;
|
|
39
|
+
export declare function loadTsManifestEntries(filePath: string, site: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<ManifestEntry[]>;
|
|
41
40
|
/**
|
|
42
41
|
* When both YAML and TS adapters exist for the same site/name,
|
|
43
42
|
* prefer the TS version (it self-registers and typically has richer logic).
|
|
44
43
|
*/
|
|
45
44
|
export declare function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean;
|
|
46
|
-
export declare function buildManifest(): ManifestEntry[]
|
|
45
|
+
export declare function buildManifest(): Promise<ManifestEntry[]>;
|
package/dist/build-manifest.js
CHANGED
|
@@ -13,109 +13,52 @@ import * as path from 'node:path';
|
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
14
|
import yaml from 'js-yaml';
|
|
15
15
|
import { getErrorMessage } from './errors.js';
|
|
16
|
+
import { fullName, getRegistry } from './registry.js';
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
|
18
19
|
const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
|
|
19
20
|
import { parseYamlArgs } from './yaml-schema.js';
|
|
20
21
|
import { isRecord } from './utils.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (ch === '\\') {
|
|
33
|
-
escaped = true;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
if (ch === quote)
|
|
37
|
-
quote = null;
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
41
|
-
quote = ch;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
if (ch === openChar) {
|
|
45
|
-
depth++;
|
|
46
|
-
}
|
|
47
|
-
else if (ch === closeChar) {
|
|
48
|
-
depth--;
|
|
49
|
-
if (depth === 0) {
|
|
50
|
-
return source.slice(startIndex + 1, i);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return null;
|
|
22
|
+
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
|
23
|
+
function toManifestArgs(args) {
|
|
24
|
+
return args.map(arg => ({
|
|
25
|
+
name: arg.name,
|
|
26
|
+
type: arg.type ?? 'str',
|
|
27
|
+
default: arg.default,
|
|
28
|
+
required: !!arg.required,
|
|
29
|
+
positional: arg.positional || undefined,
|
|
30
|
+
help: arg.help ?? '',
|
|
31
|
+
choices: arg.choices,
|
|
32
|
+
}));
|
|
55
33
|
}
|
|
56
|
-
function
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
return null;
|
|
60
|
-
const bracketIndex = source.indexOf('[', argsMatch.index);
|
|
61
|
-
if (bracketIndex === -1)
|
|
62
|
-
return null;
|
|
63
|
-
return extractBalancedBlock(source, bracketIndex, '[', ']');
|
|
34
|
+
function toTsModulePath(filePath, site) {
|
|
35
|
+
const baseName = path.basename(filePath, path.extname(filePath));
|
|
36
|
+
return `${site}/${baseName}.js`;
|
|
64
37
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
.map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
|
|
72
|
-
.filter(Boolean);
|
|
73
|
-
return values.length > 0 ? values : undefined;
|
|
38
|
+
function isCliCommandValue(value, site) {
|
|
39
|
+
return isRecord(value)
|
|
40
|
+
&& typeof value.site === 'string'
|
|
41
|
+
&& value.site === site
|
|
42
|
+
&& typeof value.name === 'string'
|
|
43
|
+
&& Array.isArray(value.args);
|
|
74
44
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (defaultMatch) {
|
|
93
|
-
const raw = defaultMatch[1].trim();
|
|
94
|
-
if (raw === 'true')
|
|
95
|
-
defaultVal = true;
|
|
96
|
-
else if (raw === 'false')
|
|
97
|
-
defaultVal = false;
|
|
98
|
-
else if (/^\d+$/.test(raw))
|
|
99
|
-
defaultVal = parseInt(raw, 10);
|
|
100
|
-
else if (/^\d+\.\d+$/.test(raw))
|
|
101
|
-
defaultVal = parseFloat(raw);
|
|
102
|
-
else
|
|
103
|
-
defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
|
|
104
|
-
}
|
|
105
|
-
args.push({
|
|
106
|
-
name: nameMatch[1],
|
|
107
|
-
type: typeMatch?.[1] ?? 'str',
|
|
108
|
-
default: defaultVal,
|
|
109
|
-
required: requiredMatch?.[1] === 'true',
|
|
110
|
-
positional: positionalMatch?.[1] === 'true' || undefined,
|
|
111
|
-
help: helpMatch?.[1] ?? '',
|
|
112
|
-
choices: parseInlineChoices(body),
|
|
113
|
-
});
|
|
114
|
-
cursor = objectStart + body.length;
|
|
115
|
-
if (cursor <= objectStart)
|
|
116
|
-
break; // safety: prevent infinite loop
|
|
117
|
-
}
|
|
118
|
-
return args;
|
|
45
|
+
function toManifestEntry(cmd, modulePath) {
|
|
46
|
+
return {
|
|
47
|
+
site: cmd.site,
|
|
48
|
+
name: cmd.name,
|
|
49
|
+
description: cmd.description ?? '',
|
|
50
|
+
domain: cmd.domain,
|
|
51
|
+
strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
|
|
52
|
+
browser: cmd.browser ?? true,
|
|
53
|
+
args: toManifestArgs(cmd.args),
|
|
54
|
+
columns: cmd.columns,
|
|
55
|
+
timeout: cmd.timeoutSeconds,
|
|
56
|
+
deprecated: cmd.deprecated,
|
|
57
|
+
replacedBy: cmd.replacedBy,
|
|
58
|
+
type: 'ts',
|
|
59
|
+
modulePath,
|
|
60
|
+
navigateBefore: cmd.navigateBefore,
|
|
61
|
+
};
|
|
119
62
|
}
|
|
120
63
|
function scanYaml(filePath, site) {
|
|
121
64
|
try {
|
|
@@ -150,82 +93,44 @@ function scanYaml(filePath, site) {
|
|
|
150
93
|
return null;
|
|
151
94
|
}
|
|
152
95
|
}
|
|
153
|
-
export function
|
|
154
|
-
// TS adapters self-register via cli() at import time.
|
|
155
|
-
// We statically parse the source to extract metadata for the manifest stub.
|
|
156
|
-
const baseName = path.basename(filePath, path.extname(filePath));
|
|
157
|
-
const relativePath = `${site}/${baseName}.js`;
|
|
96
|
+
export async function loadTsManifestEntries(filePath, site, importer = moduleHref => import(moduleHref)) {
|
|
158
97
|
try {
|
|
159
98
|
const src = fs.readFileSync(filePath, 'utf-8');
|
|
160
99
|
// Helper/test modules should not appear as CLI commands in the manifest.
|
|
161
|
-
if (
|
|
162
|
-
return
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
entry.browser = entry.strategy !== 'public';
|
|
191
|
-
// Extract columns
|
|
192
|
-
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
|
|
193
|
-
if (colMatch) {
|
|
194
|
-
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
|
|
195
|
-
}
|
|
196
|
-
// Extract args array items: { name: '...', ... }
|
|
197
|
-
const argsBlock = extractTsArgsBlock(src);
|
|
198
|
-
if (argsBlock) {
|
|
199
|
-
entry.args = parseTsArgsBlock(argsBlock);
|
|
200
|
-
}
|
|
201
|
-
// Extract navigateBefore: false / true / 'https://...'
|
|
202
|
-
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
|
|
203
|
-
if (navBoolMatch) {
|
|
204
|
-
entry.navigateBefore = navBoolMatch[1] === 'true';
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
208
|
-
if (navStringMatch)
|
|
209
|
-
entry.navigateBefore = navStringMatch[1];
|
|
210
|
-
}
|
|
211
|
-
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
|
|
212
|
-
if (deprecatedBoolMatch) {
|
|
213
|
-
entry.deprecated = deprecatedBoolMatch[1] === 'true';
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
217
|
-
if (deprecatedStringMatch)
|
|
218
|
-
entry.deprecated = deprecatedStringMatch[1];
|
|
219
|
-
}
|
|
220
|
-
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
221
|
-
if (replacedByMatch)
|
|
222
|
-
entry.replacedBy = replacedByMatch[1];
|
|
223
|
-
return entry;
|
|
100
|
+
if (!CLI_MODULE_PATTERN.test(src))
|
|
101
|
+
return [];
|
|
102
|
+
const modulePath = toTsModulePath(filePath, site);
|
|
103
|
+
const registry = getRegistry();
|
|
104
|
+
const before = new Map(registry.entries());
|
|
105
|
+
const mod = await importer(pathToFileURL(filePath).href);
|
|
106
|
+
const exportedCommands = Object.values(isRecord(mod) ? mod : {})
|
|
107
|
+
.filter(value => isCliCommandValue(value, site));
|
|
108
|
+
const runtimeCommands = exportedCommands.length > 0
|
|
109
|
+
? exportedCommands
|
|
110
|
+
: [...registry.entries()]
|
|
111
|
+
.filter(([key, cmd]) => {
|
|
112
|
+
if (cmd.site !== site)
|
|
113
|
+
return false;
|
|
114
|
+
const previous = before.get(key);
|
|
115
|
+
return !previous || previous !== cmd;
|
|
116
|
+
})
|
|
117
|
+
.map(([, cmd]) => cmd);
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
return runtimeCommands
|
|
120
|
+
.filter((cmd) => {
|
|
121
|
+
const key = fullName(cmd);
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
return false;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
return true;
|
|
126
|
+
})
|
|
127
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
128
|
+
.map(cmd => toManifestEntry(cmd, modulePath));
|
|
224
129
|
}
|
|
225
130
|
catch (err) {
|
|
226
131
|
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
|
|
227
132
|
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
|
|
228
|
-
return
|
|
133
|
+
return [];
|
|
229
134
|
}
|
|
230
135
|
}
|
|
231
136
|
/**
|
|
@@ -237,7 +142,7 @@ export function shouldReplaceManifestEntry(current, next) {
|
|
|
237
142
|
return false;
|
|
238
143
|
return current.type === 'yaml' && next.type === 'ts';
|
|
239
144
|
}
|
|
240
|
-
export function buildManifest() {
|
|
145
|
+
export async function buildManifest() {
|
|
241
146
|
const manifest = new Map();
|
|
242
147
|
if (fs.existsSync(CLIS_DIR)) {
|
|
243
148
|
for (const site of fs.readdirSync(CLIS_DIR)) {
|
|
@@ -261,8 +166,8 @@ export function buildManifest() {
|
|
|
261
166
|
}
|
|
262
167
|
else if ((file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
|
|
263
168
|
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')) {
|
|
264
|
-
const
|
|
265
|
-
|
|
169
|
+
const entries = await loadTsManifestEntries(filePath, site);
|
|
170
|
+
for (const entry of entries) {
|
|
266
171
|
const key = `${entry.site}/${entry.name}`;
|
|
267
172
|
const existing = manifest.get(key);
|
|
268
173
|
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
|
|
@@ -278,8 +183,8 @@ export function buildManifest() {
|
|
|
278
183
|
}
|
|
279
184
|
return [...manifest.values()];
|
|
280
185
|
}
|
|
281
|
-
function main() {
|
|
282
|
-
const manifest = buildManifest();
|
|
186
|
+
async function main() {
|
|
187
|
+
const manifest = await buildManifest();
|
|
283
188
|
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
|
284
189
|
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
|
|
285
190
|
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
|
|
@@ -311,5 +216,5 @@ function main() {
|
|
|
311
216
|
}
|
|
312
217
|
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
|
313
218
|
if (entrypoint === import.meta.url) {
|
|
314
|
-
main();
|
|
219
|
+
void main();
|
|
315
220
|
}
|