@jackwener/opencli 0.5.1 → 0.6.0
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 +3 -2
- package/README.zh-CN.md +4 -3
- package/SKILL.md +7 -4
- package/dist/browser.d.ts +7 -3
- package/dist/browser.js +25 -92
- package/dist/browser.test.js +18 -1
- package/dist/cascade.d.ts +1 -1
- package/dist/cascade.js +42 -75
- package/dist/cli-manifest.json +80 -0
- package/dist/clis/coupang/add-to-cart.d.ts +1 -0
- package/dist/clis/coupang/add-to-cart.js +141 -0
- package/dist/clis/coupang/search.d.ts +1 -0
- package/dist/clis/coupang/search.js +453 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +30 -0
- package/dist/coupang.d.ts +24 -0
- package/dist/coupang.js +262 -0
- package/dist/coupang.test.d.ts +1 -0
- package/dist/coupang.test.js +62 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +226 -25
- package/dist/doctor.test.js +13 -6
- package/dist/engine.js +3 -3
- package/dist/engine.test.d.ts +4 -0
- package/dist/engine.test.js +67 -0
- package/dist/explore.js +1 -15
- package/dist/interceptor.d.ts +42 -0
- package/dist/interceptor.js +138 -0
- package/dist/main.js +8 -4
- package/dist/output.js +0 -5
- package/dist/pipeline/steps/intercept.js +4 -54
- package/dist/pipeline/steps/tap.js +11 -51
- package/dist/registry.d.ts +3 -1
- package/dist/registry.test.d.ts +4 -0
- package/dist/registry.test.js +90 -0
- package/dist/runtime.d.ts +15 -1
- package/dist/runtime.js +11 -6
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +145 -0
- package/dist/synthesize.js +5 -5
- package/dist/tui.d.ts +22 -0
- package/dist/tui.js +139 -0
- package/dist/validate.js +21 -0
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +7 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.js +16 -0
- package/package.json +1 -1
- package/src/browser.test.ts +20 -1
- package/src/browser.ts +25 -87
- package/src/cascade.ts +47 -75
- package/src/clis/coupang/add-to-cart.ts +149 -0
- package/src/clis/coupang/search.ts +466 -0
- package/src/constants.ts +35 -0
- package/src/coupang.test.ts +78 -0
- package/src/coupang.ts +302 -0
- package/src/doctor.test.ts +15 -6
- package/src/doctor.ts +221 -25
- package/src/engine.test.ts +77 -0
- package/src/engine.ts +5 -5
- package/src/explore.ts +2 -15
- package/src/interceptor.ts +153 -0
- package/src/main.ts +9 -5
- package/src/output.ts +0 -4
- package/src/pipeline/executor.ts +15 -15
- package/src/pipeline/steps/intercept.ts +4 -55
- package/src/pipeline/steps/tap.ts +12 -51
- package/src/registry.test.ts +106 -0
- package/src/registry.ts +4 -1
- package/src/runtime.ts +22 -8
- package/src/setup.ts +169 -0
- package/src/synthesize.ts +5 -5
- package/src/tui.ts +171 -0
- package/src/validate.ts +22 -0
- package/src/verify.ts +10 -1
- package/src/version.ts +18 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XHR/Fetch interceptor JavaScript generators.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single source of truth for monkey-patching browser
|
|
5
|
+
* fetch() and XMLHttpRequest to capture API responses matching
|
|
6
|
+
* a URL pattern. Used by:
|
|
7
|
+
* - Page.installInterceptor() (browser.ts)
|
|
8
|
+
* - stepIntercept (pipeline/steps/intercept.ts)
|
|
9
|
+
* - stepTap (pipeline/steps/tap.ts)
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Generate JavaScript source that installs a fetch/XHR interceptor.
|
|
13
|
+
* Captured responses are pushed to `window.__opencli_intercepted`.
|
|
14
|
+
*
|
|
15
|
+
* @param patternExpr - JS expression resolving to a URL substring to match (e.g. a JSON.stringify'd string)
|
|
16
|
+
* @param opts.arrayName - Global array name for captured data (default: '__opencli_intercepted')
|
|
17
|
+
* @param opts.patchGuard - Global boolean name to prevent double-patching (default: '__opencli_interceptor_patched')
|
|
18
|
+
*/
|
|
19
|
+
export function generateInterceptorJs(patternExpr, opts = {}) {
|
|
20
|
+
const arr = opts.arrayName ?? '__opencli_intercepted';
|
|
21
|
+
const guard = opts.patchGuard ?? '__opencli_interceptor_patched';
|
|
22
|
+
return `
|
|
23
|
+
() => {
|
|
24
|
+
window.${arr} = window.${arr} || [];
|
|
25
|
+
const __pattern = ${patternExpr};
|
|
26
|
+
|
|
27
|
+
if (!window.${guard}) {
|
|
28
|
+
const __checkMatch = (url) => __pattern && url.includes(__pattern);
|
|
29
|
+
|
|
30
|
+
// ── Patch fetch ──
|
|
31
|
+
const __origFetch = window.fetch;
|
|
32
|
+
window.fetch = async function(...args) {
|
|
33
|
+
const reqUrl = typeof args[0] === 'string' ? args[0]
|
|
34
|
+
: (args[0] && args[0].url) || '';
|
|
35
|
+
const response = await __origFetch.apply(this, args);
|
|
36
|
+
if (__checkMatch(reqUrl)) {
|
|
37
|
+
try {
|
|
38
|
+
const clone = response.clone();
|
|
39
|
+
const json = await clone.json();
|
|
40
|
+
window.${arr}.push(json);
|
|
41
|
+
} catch(e) {}
|
|
42
|
+
}
|
|
43
|
+
return response;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Patch XMLHttpRequest ──
|
|
47
|
+
const __XHR = XMLHttpRequest.prototype;
|
|
48
|
+
const __origOpen = __XHR.open;
|
|
49
|
+
const __origSend = __XHR.send;
|
|
50
|
+
__XHR.open = function(method, url) {
|
|
51
|
+
this.__opencli_url = String(url);
|
|
52
|
+
return __origOpen.apply(this, arguments);
|
|
53
|
+
};
|
|
54
|
+
__XHR.send = function() {
|
|
55
|
+
if (__checkMatch(this.__opencli_url)) {
|
|
56
|
+
this.addEventListener('load', function() {
|
|
57
|
+
try {
|
|
58
|
+
window.${arr}.push(JSON.parse(this.responseText));
|
|
59
|
+
} catch(e) {}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return __origSend.apply(this, arguments);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
window.${guard} = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generate JavaScript source to read and clear intercepted data.
|
|
72
|
+
*/
|
|
73
|
+
export function generateReadInterceptedJs(arrayName = '__opencli_intercepted') {
|
|
74
|
+
return `
|
|
75
|
+
() => {
|
|
76
|
+
const data = window.${arrayName} || [];
|
|
77
|
+
window.${arrayName} = [];
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Generate a self-contained tap interceptor for store-action bridge.
|
|
84
|
+
* Unlike the global interceptor, this one:
|
|
85
|
+
* - Installs temporarily, restores originals in finally block
|
|
86
|
+
* - Resolves a promise on first capture (for immediate await)
|
|
87
|
+
* - Returns captured data directly
|
|
88
|
+
*/
|
|
89
|
+
export function generateTapInterceptorJs(patternExpr) {
|
|
90
|
+
return {
|
|
91
|
+
setupVar: `
|
|
92
|
+
let captured = null;
|
|
93
|
+
let captureResolve;
|
|
94
|
+
const capturePromise = new Promise(r => { captureResolve = r; });
|
|
95
|
+
const capturePattern = ${patternExpr};
|
|
96
|
+
`,
|
|
97
|
+
capturedVar: 'captured',
|
|
98
|
+
promiseVar: 'capturePromise',
|
|
99
|
+
resolveVar: 'captureResolve',
|
|
100
|
+
fetchPatch: `
|
|
101
|
+
const origFetch = window.fetch;
|
|
102
|
+
window.fetch = async function(...fetchArgs) {
|
|
103
|
+
const resp = await origFetch.apply(this, fetchArgs);
|
|
104
|
+
try {
|
|
105
|
+
const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
|
|
106
|
+
: fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
|
|
107
|
+
if (capturePattern && url.includes(capturePattern) && !captured) {
|
|
108
|
+
try { captured = await resp.clone().json(); captureResolve(); } catch {}
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
return resp;
|
|
112
|
+
};
|
|
113
|
+
`,
|
|
114
|
+
xhrPatch: `
|
|
115
|
+
const origXhrOpen = XMLHttpRequest.prototype.open;
|
|
116
|
+
const origXhrSend = XMLHttpRequest.prototype.send;
|
|
117
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
118
|
+
this.__tapUrl = String(url);
|
|
119
|
+
return origXhrOpen.apply(this, arguments);
|
|
120
|
+
};
|
|
121
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
122
|
+
if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
|
|
123
|
+
this.addEventListener('load', function() {
|
|
124
|
+
if (!captured) {
|
|
125
|
+
try { captured = JSON.parse(this.responseText); captureResolve(); } catch {}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return origXhrSend.apply(this, arguments);
|
|
130
|
+
};
|
|
131
|
+
`,
|
|
132
|
+
restorePatch: `
|
|
133
|
+
window.fetch = origFetch;
|
|
134
|
+
XMLHttpRequest.prototype.open = origXhrOpen;
|
|
135
|
+
XMLHttpRequest.prototype.send = origXhrSend;
|
|
136
|
+
`,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* opencli — Make any website your CLI. AI-powered.
|
|
4
4
|
*/
|
|
5
|
-
import * as fs from 'node:fs';
|
|
6
5
|
import * as os from 'node:os';
|
|
7
6
|
import * as path from 'node:path';
|
|
8
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -13,13 +12,11 @@ import { fullName, getRegistry, strategyLabel } from './registry.js';
|
|
|
13
12
|
import { render as renderOutput } from './output.js';
|
|
14
13
|
import { PlaywrightMCP } from './browser.js';
|
|
15
14
|
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
|
15
|
+
import { PKG_VERSION } from './version.js';
|
|
16
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
17
|
const __dirname = path.dirname(__filename);
|
|
18
18
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|
|
19
19
|
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
|
20
|
-
// Read version from package.json (single source of truth)
|
|
21
|
-
const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
22
|
-
const PKG_VERSION = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? '0.0.0';
|
|
23
20
|
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
|
24
21
|
const program = new Command();
|
|
25
22
|
program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
|
|
@@ -117,6 +114,13 @@ program.command('doctor')
|
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
116
|
});
|
|
117
|
+
program.command('setup')
|
|
118
|
+
.description('Interactive setup: configure Playwright MCP token across all detected tools')
|
|
119
|
+
.option('--token <token>', 'Provide token directly instead of auto-detecting')
|
|
120
|
+
.action(async (opts) => {
|
|
121
|
+
const { runSetup } = await import('./setup.js');
|
|
122
|
+
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
|
|
123
|
+
});
|
|
120
124
|
// ── Dynamic site commands ──────────────────────────────────────────────────
|
|
121
125
|
const registry = getRegistry();
|
|
122
126
|
const siteGroups = new Map();
|
package/dist/output.js
CHANGED
|
@@ -43,11 +43,6 @@ function renderTable(data, opts) {
|
|
|
43
43
|
style: { head: [], border: [] },
|
|
44
44
|
wordWrap: true,
|
|
45
45
|
wrapOnWordBoundary: true,
|
|
46
|
-
colWidths: columns.map((_c, i) => {
|
|
47
|
-
if (i === 0)
|
|
48
|
-
return 6;
|
|
49
|
-
return null;
|
|
50
|
-
}).filter(() => true),
|
|
51
46
|
});
|
|
52
47
|
for (const row of rows) {
|
|
53
48
|
table.push(columns.map(c => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Pipeline step: intercept — declarative XHR interception.
|
|
3
3
|
*/
|
|
4
4
|
import { render } from '../template.js';
|
|
5
|
+
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
|
|
5
6
|
export async function stepIntercept(page, params, data, args) {
|
|
6
7
|
const cfg = typeof params === 'object' ? params : {};
|
|
7
8
|
const trigger = cfg.trigger ?? '';
|
|
@@ -11,52 +12,7 @@ export async function stepIntercept(page, params, data, args) {
|
|
|
11
12
|
if (!capturePattern)
|
|
12
13
|
return data;
|
|
13
14
|
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
|
|
14
|
-
await page.evaluate(
|
|
15
|
-
() => {
|
|
16
|
-
window.__opencli_intercepted = window.__opencli_intercepted || [];
|
|
17
|
-
const pattern = ${JSON.stringify(capturePattern)};
|
|
18
|
-
|
|
19
|
-
if (!window.__opencli_fetch_patched) {
|
|
20
|
-
const origFetch = window.fetch;
|
|
21
|
-
window.fetch = async function(...args) {
|
|
22
|
-
const reqUrl = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
23
|
-
const response = await origFetch.apply(this, args);
|
|
24
|
-
setTimeout(async () => {
|
|
25
|
-
try {
|
|
26
|
-
if (reqUrl.includes(pattern)) {
|
|
27
|
-
const clone = response.clone();
|
|
28
|
-
const json = await clone.json();
|
|
29
|
-
window.__opencli_intercepted.push(json);
|
|
30
|
-
}
|
|
31
|
-
} catch(e) {}
|
|
32
|
-
}, 0);
|
|
33
|
-
return response;
|
|
34
|
-
};
|
|
35
|
-
window.__opencli_fetch_patched = true;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (!window.__opencli_xhr_patched) {
|
|
39
|
-
const XHR = XMLHttpRequest.prototype;
|
|
40
|
-
const open = XHR.open;
|
|
41
|
-
const send = XHR.send;
|
|
42
|
-
XHR.open = function(method, url, ...args) {
|
|
43
|
-
this._reqUrl = url;
|
|
44
|
-
return open.call(this, method, url, ...args);
|
|
45
|
-
};
|
|
46
|
-
XHR.send = function(...args) {
|
|
47
|
-
this.addEventListener('load', function() {
|
|
48
|
-
try {
|
|
49
|
-
if (this._reqUrl && this._reqUrl.includes(pattern)) {
|
|
50
|
-
window.__opencli_intercepted.push(JSON.parse(this.responseText));
|
|
51
|
-
}
|
|
52
|
-
} catch(e) {}
|
|
53
|
-
});
|
|
54
|
-
return send.apply(this, args);
|
|
55
|
-
};
|
|
56
|
-
window.__opencli_xhr_patched = true;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
`);
|
|
15
|
+
await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
|
|
60
16
|
// Step 2: Execute the trigger action
|
|
61
17
|
if (trigger.startsWith('navigate:')) {
|
|
62
18
|
const url = render(trigger.slice('navigate:'.length), { args, data });
|
|
@@ -77,14 +33,8 @@ export async function stepIntercept(page, params, data, args) {
|
|
|
77
33
|
// Step 3: Wait a bit for network requests to fire
|
|
78
34
|
await page.wait(Math.min(timeout, 3));
|
|
79
35
|
// Step 4: Retrieve captured data
|
|
80
|
-
const matchingResponses = await page.evaluate(
|
|
81
|
-
|
|
82
|
-
const data = window.__opencli_intercepted || [];
|
|
83
|
-
window.__opencli_intercepted = []; // clear after reading
|
|
84
|
-
return data;
|
|
85
|
-
}
|
|
86
|
-
`);
|
|
87
|
-
// Step 4: Select from response if specified
|
|
36
|
+
const matchingResponses = await page.evaluate(generateReadInterceptedJs());
|
|
37
|
+
// Step 5: Select from response if specified
|
|
88
38
|
let result = matchingResponses.length === 1 ? matchingResponses[0] :
|
|
89
39
|
matchingResponses.length > 1 ? matchingResponses : data;
|
|
90
40
|
if (selectPath && result) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 5. Returns the captured data (optionally sub-selected)
|
|
10
10
|
*/
|
|
11
11
|
import { render } from '../template.js';
|
|
12
|
+
import { generateTapInterceptorJs } from '../../interceptor.js';
|
|
12
13
|
export async function stepTap(page, params, data, args) {
|
|
13
14
|
const cfg = typeof params === 'object' ? params : {};
|
|
14
15
|
const storeName = String(render(cfg.store ?? '', { args, data }));
|
|
@@ -32,53 +33,14 @@ export async function stepTap(page, params, data, args) {
|
|
|
32
33
|
const actionCall = actionArgsRendered.length
|
|
33
34
|
? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})`
|
|
34
35
|
: `store[${JSON.stringify(actionName)}]()`;
|
|
36
|
+
// Use shared interceptor generator for fetch/XHR patching
|
|
37
|
+
const tap = generateTapInterceptorJs(JSON.stringify(capturePattern));
|
|
35
38
|
const js = `
|
|
36
39
|
async () => {
|
|
37
40
|
// ── 1. Setup capture proxy (fetch + XHR dual interception) ──
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const capturePattern = ${JSON.stringify(capturePattern)};
|
|
42
|
-
|
|
43
|
-
// Intercept fetch API
|
|
44
|
-
const origFetch = window.fetch;
|
|
45
|
-
window.fetch = async function(...fetchArgs) {
|
|
46
|
-
const resp = await origFetch.apply(this, fetchArgs);
|
|
47
|
-
try {
|
|
48
|
-
const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
|
|
49
|
-
: fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
|
|
50
|
-
if (capturePattern && url.includes(capturePattern) && !captured) {
|
|
51
|
-
try { captured = await resp.clone().json(); captureResolve(); } catch {}
|
|
52
|
-
}
|
|
53
|
-
} catch {}
|
|
54
|
-
return resp;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// Intercept XMLHttpRequest
|
|
58
|
-
const origXhrOpen = XMLHttpRequest.prototype.open;
|
|
59
|
-
const origXhrSend = XMLHttpRequest.prototype.send;
|
|
60
|
-
XMLHttpRequest.prototype.open = function(method, url) {
|
|
61
|
-
this.__tapUrl = String(url);
|
|
62
|
-
return origXhrOpen.apply(this, arguments);
|
|
63
|
-
};
|
|
64
|
-
XMLHttpRequest.prototype.send = function(body) {
|
|
65
|
-
if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
|
|
66
|
-
const xhr = this;
|
|
67
|
-
const origHandler = xhr.onreadystatechange;
|
|
68
|
-
xhr.onreadystatechange = function() {
|
|
69
|
-
if (xhr.readyState === 4 && !captured) {
|
|
70
|
-
try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {}
|
|
71
|
-
}
|
|
72
|
-
if (origHandler) origHandler.apply(this, arguments);
|
|
73
|
-
};
|
|
74
|
-
const origOnload = xhr.onload;
|
|
75
|
-
xhr.onload = function() {
|
|
76
|
-
if (!captured) { try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {} }
|
|
77
|
-
if (origOnload) origOnload.apply(this, arguments);
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
return origXhrSend.apply(this, arguments);
|
|
81
|
-
};
|
|
41
|
+
${tap.setupVar}
|
|
42
|
+
${tap.fetchPatch}
|
|
43
|
+
${tap.xhrPatch}
|
|
82
44
|
|
|
83
45
|
try {
|
|
84
46
|
// ── 2. Find store ──
|
|
@@ -113,19 +75,17 @@ export async function stepTap(page, params, data, args) {
|
|
|
113
75
|
await ${actionCall};
|
|
114
76
|
|
|
115
77
|
// ── 4. Wait for network response ──
|
|
116
|
-
if (
|
|
78
|
+
if (!${tap.capturedVar}) {
|
|
117
79
|
const timeoutPromise = new Promise(r => setTimeout(r, ${timeout} * 1000));
|
|
118
|
-
await Promise.race([
|
|
80
|
+
await Promise.race([${tap.promiseVar}, timeoutPromise]);
|
|
119
81
|
}
|
|
120
82
|
} finally {
|
|
121
83
|
// ── 5. Always restore originals ──
|
|
122
|
-
|
|
123
|
-
XMLHttpRequest.prototype.open = origXhrOpen;
|
|
124
|
-
XMLHttpRequest.prototype.send = origXhrSend;
|
|
84
|
+
${tap.restorePatch}
|
|
125
85
|
}
|
|
126
86
|
|
|
127
|
-
if (
|
|
128
|
-
return
|
|
87
|
+
if (!${tap.capturedVar}) return { error: 'No matching response captured for pattern: ' + capturePattern };
|
|
88
|
+
return ${tap.capturedVar}${selectChain} ?? ${tap.capturedVar};
|
|
129
89
|
}
|
|
130
90
|
`;
|
|
131
91
|
return page.evaluate(js);
|
package/dist/registry.d.ts
CHANGED
|
@@ -30,7 +30,9 @@ export interface CliCommand {
|
|
|
30
30
|
pipeline?: any[];
|
|
31
31
|
timeoutSeconds?: number;
|
|
32
32
|
source?: string;
|
|
33
|
-
|
|
33
|
+
}
|
|
34
|
+
/** Internal extension for lazy-loaded TS modules (not exposed in public API) */
|
|
35
|
+
export interface InternalCliCommand extends CliCommand {
|
|
34
36
|
_lazy?: boolean;
|
|
35
37
|
_modulePath?: string;
|
|
36
38
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for registry.ts: Strategy enum, cli() registration, helpers.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { cli, getRegistry, fullName, strategyLabel, registerCommand, Strategy } from './registry.js';
|
|
6
|
+
describe('cli() registration', () => {
|
|
7
|
+
it('registers a command and returns it', () => {
|
|
8
|
+
const cmd = cli({
|
|
9
|
+
site: 'test-registry',
|
|
10
|
+
name: 'hello',
|
|
11
|
+
description: 'A test command',
|
|
12
|
+
strategy: Strategy.PUBLIC,
|
|
13
|
+
browser: false,
|
|
14
|
+
});
|
|
15
|
+
expect(cmd.site).toBe('test-registry');
|
|
16
|
+
expect(cmd.name).toBe('hello');
|
|
17
|
+
expect(cmd.strategy).toBe(Strategy.PUBLIC);
|
|
18
|
+
expect(cmd.browser).toBe(false);
|
|
19
|
+
expect(cmd.args).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
it('puts registered command in the registry', () => {
|
|
22
|
+
cli({
|
|
23
|
+
site: 'test-registry',
|
|
24
|
+
name: 'registered',
|
|
25
|
+
description: 'test',
|
|
26
|
+
});
|
|
27
|
+
const registry = getRegistry();
|
|
28
|
+
expect(registry.has('test-registry/registered')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('defaults strategy to COOKIE when browser is true', () => {
|
|
31
|
+
const cmd = cli({
|
|
32
|
+
site: 'test-registry',
|
|
33
|
+
name: 'default-strategy',
|
|
34
|
+
});
|
|
35
|
+
expect(cmd.strategy).toBe(Strategy.COOKIE);
|
|
36
|
+
expect(cmd.browser).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('defaults strategy to PUBLIC when browser is false', () => {
|
|
39
|
+
const cmd = cli({
|
|
40
|
+
site: 'test-registry',
|
|
41
|
+
name: 'no-browser',
|
|
42
|
+
browser: false,
|
|
43
|
+
});
|
|
44
|
+
expect(cmd.strategy).toBe(Strategy.PUBLIC);
|
|
45
|
+
});
|
|
46
|
+
it('overwrites existing command on re-registration', () => {
|
|
47
|
+
cli({ site: 'test-registry', name: 'overwrite', description: 'v1' });
|
|
48
|
+
cli({ site: 'test-registry', name: 'overwrite', description: 'v2' });
|
|
49
|
+
const reg = getRegistry();
|
|
50
|
+
expect(reg.get('test-registry/overwrite')?.description).toBe('v2');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('fullName', () => {
|
|
54
|
+
it('returns site/name', () => {
|
|
55
|
+
const cmd = {
|
|
56
|
+
site: 'bilibili', name: 'hot', description: '', args: [],
|
|
57
|
+
};
|
|
58
|
+
expect(fullName(cmd)).toBe('bilibili/hot');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('strategyLabel', () => {
|
|
62
|
+
it('returns strategy string', () => {
|
|
63
|
+
const cmd = {
|
|
64
|
+
site: 'test', name: 'test', description: '', args: [],
|
|
65
|
+
strategy: Strategy.INTERCEPT,
|
|
66
|
+
};
|
|
67
|
+
expect(strategyLabel(cmd)).toBe('intercept');
|
|
68
|
+
});
|
|
69
|
+
it('returns public when no strategy set', () => {
|
|
70
|
+
const cmd = {
|
|
71
|
+
site: 'test', name: 'test', description: '', args: [],
|
|
72
|
+
};
|
|
73
|
+
expect(strategyLabel(cmd)).toBe('public');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('registerCommand', () => {
|
|
77
|
+
it('registers a pre-built command', () => {
|
|
78
|
+
const cmd = {
|
|
79
|
+
site: 'test-registry',
|
|
80
|
+
name: 'direct-reg',
|
|
81
|
+
description: 'directly registered',
|
|
82
|
+
args: [],
|
|
83
|
+
strategy: Strategy.HEADER,
|
|
84
|
+
browser: true,
|
|
85
|
+
};
|
|
86
|
+
registerCommand(cmd);
|
|
87
|
+
const reg = getRegistry();
|
|
88
|
+
expect(reg.get('test-registry/direct-reg')?.strategy).toBe(Strategy.HEADER);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/dist/runtime.d.ts
CHANGED
|
@@ -6,8 +6,22 @@ export declare const DEFAULT_BROWSER_CONNECT_TIMEOUT: number;
|
|
|
6
6
|
export declare const DEFAULT_BROWSER_COMMAND_TIMEOUT: number;
|
|
7
7
|
export declare const DEFAULT_BROWSER_EXPLORE_TIMEOUT: number;
|
|
8
8
|
export declare const DEFAULT_BROWSER_SMOKE_TIMEOUT: number;
|
|
9
|
+
/**
|
|
10
|
+
* Timeout with seconds unit. Used for high-level command timeouts.
|
|
11
|
+
*/
|
|
9
12
|
export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
|
|
10
13
|
timeout: number;
|
|
11
14
|
label?: string;
|
|
12
15
|
}): Promise<T>;
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Timeout with milliseconds unit. Used for low-level internal timeouts.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
|
|
20
|
+
/** Interface for browser factory (PlaywrightMCP or test mocks) */
|
|
21
|
+
export interface IBrowserFactory {
|
|
22
|
+
connect(opts?: {
|
|
23
|
+
timeout?: number;
|
|
24
|
+
}): Promise<IPage>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function browserSession<T>(BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise<T>): Promise<T>;
|
package/dist/runtime.js
CHANGED
|
@@ -5,14 +5,19 @@ export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROW
|
|
|
5
5
|
export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_COMMAND_TIMEOUT ?? '45', 10);
|
|
6
6
|
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_EXPLORE_TIMEOUT ?? '120', 10);
|
|
7
7
|
export const DEFAULT_BROWSER_SMOKE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_SMOKE_TIMEOUT ?? '60', 10);
|
|
8
|
+
/**
|
|
9
|
+
* Timeout with seconds unit. Used for high-level command timeouts.
|
|
10
|
+
*/
|
|
8
11
|
export async function runWithTimeout(promise, opts) {
|
|
12
|
+
return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Timeout with milliseconds unit. Used for low-level internal timeouts.
|
|
16
|
+
*/
|
|
17
|
+
export function withTimeoutMs(promise, timeoutMs, message) {
|
|
9
18
|
return new Promise((resolve, reject) => {
|
|
10
|
-
const timer = setTimeout(() =>
|
|
11
|
-
|
|
12
|
-
}, opts.timeout * 1000);
|
|
13
|
-
promise
|
|
14
|
-
.then((result) => { clearTimeout(timer); resolve(result); })
|
|
15
|
-
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
19
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
20
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
16
21
|
});
|
|
17
22
|
}
|
|
18
23
|
export async function browserSession(BrowserFactory, fn) {
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* setup.ts — Interactive Playwright MCP token setup
|
|
3
|
+
*
|
|
4
|
+
* Discovers the extension token, shows an interactive checkbox
|
|
5
|
+
* for selecting which config files to update, and applies changes.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
11
|
+
import { PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
|
|
12
|
+
import { getTokenFingerprint } from './browser.js';
|
|
13
|
+
import { checkboxPrompt } from './tui.js';
|
|
14
|
+
export async function runSetup(opts = {}) {
|
|
15
|
+
console.log();
|
|
16
|
+
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
|
|
17
|
+
console.log();
|
|
18
|
+
// Step 1: Discover token
|
|
19
|
+
let token = opts.token ?? null;
|
|
20
|
+
if (!token) {
|
|
21
|
+
const extensionToken = discoverExtensionToken();
|
|
22
|
+
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
23
|
+
if (extensionToken && envToken && extensionToken === envToken) {
|
|
24
|
+
token = extensionToken;
|
|
25
|
+
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
|
|
26
|
+
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
|
|
27
|
+
}
|
|
28
|
+
else if (extensionToken) {
|
|
29
|
+
token = extensionToken;
|
|
30
|
+
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
|
|
31
|
+
chalk.dim(`(${getTokenFingerprint(token)})`));
|
|
32
|
+
if (envToken && envToken !== extensionToken) {
|
|
33
|
+
console.log(` ${chalk.yellow('!')} Environment has different token ` +
|
|
34
|
+
chalk.dim(`(${getTokenFingerprint(envToken)})`));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (envToken) {
|
|
38
|
+
token = envToken;
|
|
39
|
+
console.log(` ${chalk.green('✓')} Token from environment variable ` +
|
|
40
|
+
chalk.dim(`(${getTokenFingerprint(token)})`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.log(` ${chalk.green('✓')} Using provided token ` +
|
|
45
|
+
chalk.dim(`(${getTokenFingerprint(token)})`));
|
|
46
|
+
}
|
|
47
|
+
if (!token) {
|
|
48
|
+
console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
|
|
49
|
+
console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
|
|
50
|
+
console.log();
|
|
51
|
+
const rl = createInterface({ input, output });
|
|
52
|
+
const answer = await rl.question(' Token: ');
|
|
53
|
+
rl.close();
|
|
54
|
+
token = answer.trim();
|
|
55
|
+
if (!token) {
|
|
56
|
+
console.log(chalk.red('\n No token provided. Aborting.\n'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
|
|
61
|
+
console.log();
|
|
62
|
+
// Step 2: Scan all config locations
|
|
63
|
+
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
|
|
64
|
+
// Step 3: Build checkbox items
|
|
65
|
+
const items = [];
|
|
66
|
+
// Shell file
|
|
67
|
+
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
|
68
|
+
const shellStatus = report.shellFiles[0];
|
|
69
|
+
const shellFp = shellStatus?.fingerprint;
|
|
70
|
+
const shellOk = shellFp === fingerprint;
|
|
71
|
+
const shellTool = toolName(shellPath) || 'Shell';
|
|
72
|
+
items.push({
|
|
73
|
+
label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
|
|
74
|
+
value: `shell:${shellPath}`,
|
|
75
|
+
checked: !shellOk,
|
|
76
|
+
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
|
|
77
|
+
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
|
|
78
|
+
});
|
|
79
|
+
// Config files
|
|
80
|
+
for (const config of report.configs) {
|
|
81
|
+
const fp = config.fingerprint;
|
|
82
|
+
const ok = fp === fingerprint;
|
|
83
|
+
const tool = toolName(config.path);
|
|
84
|
+
items.push({
|
|
85
|
+
label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
|
|
86
|
+
value: `config:${config.path}`,
|
|
87
|
+
checked: !ok,
|
|
88
|
+
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
|
|
89
|
+
statusColor: ok ? 'green' : 'yellow',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Step 4: Show interactive checkbox
|
|
93
|
+
console.clear();
|
|
94
|
+
const selected = await checkboxPrompt(items, {
|
|
95
|
+
title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
|
|
96
|
+
});
|
|
97
|
+
if (selected.length === 0) {
|
|
98
|
+
console.log(chalk.dim(' No changes made.\n'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Step 5: Apply changes
|
|
102
|
+
const written = [];
|
|
103
|
+
let wroteShell = false;
|
|
104
|
+
for (const sel of selected) {
|
|
105
|
+
if (sel.startsWith('shell:')) {
|
|
106
|
+
const p = sel.slice('shell:'.length);
|
|
107
|
+
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
108
|
+
writeFileWithMkdir(p, upsertShellToken(before, token));
|
|
109
|
+
written.push(p);
|
|
110
|
+
wroteShell = true;
|
|
111
|
+
}
|
|
112
|
+
else if (sel.startsWith('config:')) {
|
|
113
|
+
const p = sel.slice('config:'.length);
|
|
114
|
+
const config = report.configs.find(c => c.path === p);
|
|
115
|
+
if (config && config.parseError)
|
|
116
|
+
continue;
|
|
117
|
+
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
118
|
+
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
|
|
119
|
+
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
|
120
|
+
writeFileWithMkdir(p, next);
|
|
121
|
+
written.push(p);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
125
|
+
// Step 6: Summary
|
|
126
|
+
if (written.length > 0) {
|
|
127
|
+
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
|
|
128
|
+
for (const p of written) {
|
|
129
|
+
const tool = toolName(p);
|
|
130
|
+
console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
|
|
131
|
+
}
|
|
132
|
+
if (wroteShell) {
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
console.log(chalk.yellow(' No files were changed.'));
|
|
139
|
+
}
|
|
140
|
+
console.log();
|
|
141
|
+
}
|
|
142
|
+
function padRight(s, n) {
|
|
143
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
144
|
+
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
|
|
145
|
+
}
|