@jackwener/opencli 1.3.1 → 1.3.2
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/CHANGELOG.md +128 -0
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/SKILL.md +317 -5
- package/TESTING.md +4 -4
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +9 -10
- package/dist/build-manifest.js +1 -3
- package/dist/cli-manifest.json +2573 -989
- package/dist/cli.js +42 -2
- package/dist/clis/bilibili/download.js +20 -65
- package/dist/clis/bilibili/utils.js +2 -1
- package/dist/clis/chaoxing/assignments.js +2 -1
- package/dist/clis/doubao/ask.d.ts +1 -0
- package/dist/clis/doubao/ask.js +35 -0
- package/dist/clis/doubao/common.d.ts +23 -0
- package/dist/clis/doubao/common.js +564 -0
- package/dist/clis/doubao/new.d.ts +1 -0
- package/dist/clis/doubao/new.js +20 -0
- package/dist/clis/doubao/read.d.ts +1 -0
- package/dist/clis/doubao/read.js +19 -0
- package/dist/clis/doubao/send.d.ts +1 -0
- package/dist/clis/doubao/send.js +22 -0
- package/dist/clis/doubao/status.d.ts +1 -0
- package/dist/clis/doubao/status.js +24 -0
- package/dist/clis/doubao-app/ask.d.ts +1 -0
- package/dist/clis/doubao-app/ask.js +53 -0
- package/dist/clis/doubao-app/common.d.ts +37 -0
- package/dist/clis/doubao-app/common.js +110 -0
- package/dist/clis/doubao-app/dump.d.ts +1 -0
- package/dist/clis/doubao-app/dump.js +24 -0
- package/dist/clis/doubao-app/new.d.ts +1 -0
- package/dist/clis/doubao-app/new.js +20 -0
- package/dist/clis/doubao-app/read.d.ts +1 -0
- package/dist/clis/doubao-app/read.js +18 -0
- package/dist/clis/doubao-app/screenshot.d.ts +1 -0
- package/dist/clis/doubao-app/screenshot.js +18 -0
- package/dist/clis/doubao-app/send.d.ts +1 -0
- package/dist/clis/doubao-app/send.js +27 -0
- package/dist/clis/doubao-app/status.d.ts +1 -0
- package/dist/clis/doubao-app/status.js +16 -0
- package/dist/clis/hackernews/ask.yaml +38 -0
- package/dist/clis/hackernews/best.yaml +38 -0
- package/dist/clis/hackernews/jobs.yaml +36 -0
- package/dist/clis/hackernews/new.yaml +38 -0
- package/dist/clis/hackernews/search.yaml +44 -0
- package/dist/clis/hackernews/show.yaml +38 -0
- package/dist/clis/hackernews/top.yaml +3 -1
- package/dist/clis/hackernews/user.yaml +25 -0
- package/dist/clis/twitter/download.js +13 -97
- package/dist/clis/twitter/thread.js +2 -1
- package/dist/clis/v2ex/member.yaml +29 -0
- package/dist/clis/v2ex/node.yaml +34 -0
- package/dist/clis/v2ex/nodes.yaml +31 -0
- package/dist/clis/v2ex/replies.yaml +32 -0
- package/dist/clis/v2ex/user.yaml +34 -0
- package/dist/clis/weibo/search.d.ts +1 -0
- package/dist/clis/weibo/search.js +73 -0
- package/dist/clis/weixin/download.d.ts +12 -0
- package/dist/clis/weixin/download.js +183 -0
- package/dist/clis/xiaohongshu/download.js +12 -60
- package/dist/clis/xiaohongshu/publish.d.ts +18 -0
- package/dist/clis/xiaohongshu/publish.js +352 -0
- package/dist/clis/xiaohongshu/search.js +47 -15
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/search.test.js +114 -0
- package/dist/clis/yollomi/background.d.ts +4 -0
- package/dist/clis/yollomi/background.js +45 -0
- package/dist/clis/yollomi/edit.d.ts +5 -0
- package/dist/clis/yollomi/edit.js +56 -0
- package/dist/clis/yollomi/face-swap.d.ts +5 -0
- package/dist/clis/yollomi/face-swap.js +43 -0
- package/dist/clis/yollomi/generate.d.ts +9 -0
- package/dist/clis/yollomi/generate.js +100 -0
- package/dist/clis/yollomi/models.d.ts +1 -0
- package/dist/clis/yollomi/models.js +33 -0
- package/dist/clis/yollomi/object-remover.d.ts +4 -0
- package/dist/clis/yollomi/object-remover.js +42 -0
- package/dist/clis/yollomi/remove-bg.d.ts +4 -0
- package/dist/clis/yollomi/remove-bg.js +38 -0
- package/dist/clis/yollomi/restore.d.ts +4 -0
- package/dist/clis/yollomi/restore.js +38 -0
- package/dist/clis/yollomi/try-on.d.ts +4 -0
- package/dist/clis/yollomi/try-on.js +46 -0
- package/dist/clis/yollomi/upload.d.ts +7 -0
- package/dist/clis/yollomi/upload.js +71 -0
- package/dist/clis/yollomi/upscale.d.ts +4 -0
- package/dist/clis/yollomi/upscale.js +53 -0
- package/dist/clis/yollomi/utils.d.ts +45 -0
- package/dist/clis/yollomi/utils.js +180 -0
- package/dist/clis/yollomi/video.d.ts +5 -0
- package/dist/clis/yollomi/video.js +56 -0
- package/dist/clis/zhihu/download.d.ts +1 -5
- package/dist/clis/zhihu/download.js +20 -126
- package/dist/clis/zhihu/download.test.js +7 -5
- package/dist/clis/zhihu/question.js +2 -1
- package/dist/commanderAdapter.js +4 -6
- package/dist/daemon.js +5 -2
- package/dist/discovery.js +10 -10
- package/dist/download/article-download.d.ts +59 -0
- package/dist/download/article-download.js +178 -0
- package/dist/download/media-download.d.ts +49 -0
- package/dist/download/media-download.js +112 -0
- package/dist/errors.d.ts +23 -2
- package/dist/errors.js +58 -2
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +59 -0
- package/dist/execution.js +9 -10
- package/dist/explore.js +4 -2
- package/dist/external.d.ts +15 -0
- package/dist/external.js +48 -2
- package/dist/external.test.d.ts +1 -0
- package/dist/external.test.js +64 -0
- package/dist/main.js +10 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +45 -23
- package/dist/plugin.test.js +6 -1
- package/dist/record.d.ts +47 -0
- package/dist/record.js +545 -0
- package/dist/registry.d.ts +7 -2
- package/dist/registry.js +2 -6
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +10 -3
- package/dist/validate.js +1 -3
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/doubao.md +35 -0
- package/docs/adapters/browser/hackernews.md +20 -4
- package/docs/adapters/browser/tiktok.md +1 -1
- package/docs/adapters/browser/v2ex.md +31 -10
- package/docs/adapters/browser/weibo.md +4 -0
- package/docs/adapters/browser/weixin.md +33 -0
- package/docs/adapters/browser/xiaohongshu.md +8 -6
- package/docs/adapters/browser/yollomi.md +69 -0
- package/docs/adapters/desktop/doubao-app.md +35 -0
- package/docs/adapters/index.md +16 -5
- package/docs/advanced/download.md +4 -0
- package/package.json +3 -1
- package/src/browser/errors.ts +17 -11
- package/src/build-manifest.ts +2 -3
- package/src/cli.ts +45 -2
- package/src/clis/bilibili/download.ts +25 -83
- package/src/clis/bilibili/utils.ts +2 -1
- package/src/clis/chaoxing/assignments.ts +2 -1
- package/src/clis/doubao/ask.ts +40 -0
- package/src/clis/doubao/common.ts +619 -0
- package/src/clis/doubao/new.ts +22 -0
- package/src/clis/doubao/read.ts +20 -0
- package/src/clis/doubao/send.ts +25 -0
- package/src/clis/doubao/status.ts +27 -0
- package/src/clis/doubao-app/ask.ts +60 -0
- package/src/clis/doubao-app/common.ts +116 -0
- package/src/clis/doubao-app/dump.ts +28 -0
- package/src/clis/doubao-app/new.ts +21 -0
- package/src/clis/doubao-app/read.ts +21 -0
- package/src/clis/doubao-app/screenshot.ts +19 -0
- package/src/clis/doubao-app/send.ts +30 -0
- package/src/clis/doubao-app/status.ts +17 -0
- package/src/clis/hackernews/ask.yaml +38 -0
- package/src/clis/hackernews/best.yaml +38 -0
- package/src/clis/hackernews/jobs.yaml +36 -0
- package/src/clis/hackernews/new.yaml +38 -0
- package/src/clis/hackernews/search.yaml +44 -0
- package/src/clis/hackernews/show.yaml +38 -0
- package/src/clis/hackernews/top.yaml +3 -1
- package/src/clis/hackernews/user.yaml +25 -0
- package/src/clis/twitter/download.ts +13 -111
- package/src/clis/twitter/thread.ts +2 -1
- package/src/clis/v2ex/member.yaml +29 -0
- package/src/clis/v2ex/node.yaml +34 -0
- package/src/clis/v2ex/nodes.yaml +31 -0
- package/src/clis/v2ex/replies.yaml +32 -0
- package/src/clis/v2ex/user.yaml +34 -0
- package/src/clis/weibo/search.ts +78 -0
- package/src/clis/weixin/download.ts +199 -0
- package/src/clis/xiaohongshu/download.ts +12 -71
- package/src/clis/xiaohongshu/publish.ts +392 -0
- package/src/clis/xiaohongshu/search.test.ts +134 -0
- package/src/clis/xiaohongshu/search.ts +49 -15
- package/src/clis/yollomi/background.ts +48 -0
- package/src/clis/yollomi/edit.ts +58 -0
- package/src/clis/yollomi/face-swap.ts +45 -0
- package/src/clis/yollomi/generate.ts +95 -0
- package/src/clis/yollomi/models.ts +38 -0
- package/src/clis/yollomi/object-remover.ts +44 -0
- package/src/clis/yollomi/remove-bg.ts +40 -0
- package/src/clis/yollomi/restore.ts +40 -0
- package/src/clis/yollomi/try-on.ts +48 -0
- package/src/clis/yollomi/upload.ts +78 -0
- package/src/clis/yollomi/upscale.ts +49 -0
- package/src/clis/yollomi/utils.ts +202 -0
- package/src/clis/yollomi/video.ts +61 -0
- package/src/clis/zhihu/download.test.ts +7 -5
- package/src/clis/zhihu/download.ts +23 -158
- package/src/clis/zhihu/question.ts +2 -1
- package/src/commanderAdapter.ts +4 -7
- package/src/daemon.ts +5 -2
- package/src/discovery.ts +26 -26
- package/src/download/article-download.ts +272 -0
- package/src/download/media-download.ts +178 -0
- package/src/errors.test.ts +79 -0
- package/src/errors.ts +92 -2
- package/src/execution.ts +14 -10
- package/src/explore.ts +4 -2
- package/src/external.test.ts +88 -0
- package/src/external.ts +56 -2
- package/src/generate.ts +2 -1
- package/src/main.ts +10 -0
- package/src/plugin.test.ts +7 -1
- package/src/plugin.ts +49 -25
- package/src/record.ts +617 -0
- package/src/registry.ts +9 -5
- package/src/runtime.ts +16 -4
- package/src/validate.ts +2 -3
- package/tests/e2e/browser-auth.test.ts +10 -1
- package/tests/e2e/browser-public.test.ts +13 -8
- package/tests/e2e/public-commands.test.ts +209 -21
- package/tests/smoke/api-health.test.ts +65 -6
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CliError, BrowserConnectError, AdapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, SelectorError, } from './errors.js';
|
|
3
|
+
describe('Error type hierarchy', () => {
|
|
4
|
+
it('all error types extend CliError', () => {
|
|
5
|
+
const errors = [
|
|
6
|
+
new BrowserConnectError('test'),
|
|
7
|
+
new AdapterLoadError('test'),
|
|
8
|
+
new CommandExecutionError('test'),
|
|
9
|
+
new ConfigError('test'),
|
|
10
|
+
new AuthRequiredError('example.com'),
|
|
11
|
+
new TimeoutError('test', 30),
|
|
12
|
+
new ArgumentError('test'),
|
|
13
|
+
new EmptyResultError('test/cmd'),
|
|
14
|
+
new SelectorError('.btn'),
|
|
15
|
+
];
|
|
16
|
+
for (const err of errors) {
|
|
17
|
+
expect(err).toBeInstanceOf(CliError);
|
|
18
|
+
expect(err).toBeInstanceOf(Error);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('AuthRequiredError has correct code, domain, and auto-generated hint', () => {
|
|
22
|
+
const err = new AuthRequiredError('bilibili.com');
|
|
23
|
+
expect(err.code).toBe('AUTH_REQUIRED');
|
|
24
|
+
expect(err.domain).toBe('bilibili.com');
|
|
25
|
+
expect(err.message).toBe('Not logged in to bilibili.com');
|
|
26
|
+
expect(err.hint).toContain('https://bilibili.com');
|
|
27
|
+
});
|
|
28
|
+
it('AuthRequiredError accepts custom message', () => {
|
|
29
|
+
const err = new AuthRequiredError('x.com', 'No ct0 cookie found');
|
|
30
|
+
expect(err.message).toBe('No ct0 cookie found');
|
|
31
|
+
expect(err.hint).toContain('https://x.com');
|
|
32
|
+
});
|
|
33
|
+
it('TimeoutError has correct code and hint', () => {
|
|
34
|
+
const err = new TimeoutError('bilibili/hot', 60);
|
|
35
|
+
expect(err.code).toBe('TIMEOUT');
|
|
36
|
+
expect(err.message).toBe('bilibili/hot timed out after 60s');
|
|
37
|
+
expect(err.hint).toContain('timeout');
|
|
38
|
+
});
|
|
39
|
+
it('ArgumentError has correct code', () => {
|
|
40
|
+
const err = new ArgumentError('Argument "limit" must be a valid number');
|
|
41
|
+
expect(err.code).toBe('ARGUMENT');
|
|
42
|
+
});
|
|
43
|
+
it('EmptyResultError has default hint', () => {
|
|
44
|
+
const err = new EmptyResultError('hackernews/top');
|
|
45
|
+
expect(err.code).toBe('EMPTY_RESULT');
|
|
46
|
+
expect(err.message).toBe('hackernews/top returned no data');
|
|
47
|
+
expect(err.hint).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
it('SelectorError has default hint about page changes', () => {
|
|
50
|
+
const err = new SelectorError('.submit-btn');
|
|
51
|
+
expect(err.code).toBe('SELECTOR');
|
|
52
|
+
expect(err.message).toContain('.submit-btn');
|
|
53
|
+
expect(err.hint).toContain('report');
|
|
54
|
+
});
|
|
55
|
+
it('BrowserConnectError has correct code', () => {
|
|
56
|
+
const err = new BrowserConnectError('Cannot connect');
|
|
57
|
+
expect(err.code).toBe('BROWSER_CONNECT');
|
|
58
|
+
});
|
|
59
|
+
});
|
package/dist/execution.js
CHANGED
|
@@ -11,14 +11,11 @@
|
|
|
11
11
|
import { Strategy, getRegistry, fullName } from './registry.js';
|
|
12
12
|
import { pathToFileURL } from 'node:url';
|
|
13
13
|
import { executePipeline } from './pipeline/index.js';
|
|
14
|
-
import { AdapterLoadError } from './errors.js';
|
|
14
|
+
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
|
|
15
15
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
16
16
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
17
17
|
/** Set of TS module paths that have been loaded */
|
|
18
18
|
const _loadedModules = new Set();
|
|
19
|
-
function getErrorMessage(error) {
|
|
20
|
-
return error instanceof Error ? error.message : String(error);
|
|
21
|
-
}
|
|
22
19
|
/**
|
|
23
20
|
* Validates and coerces arguments based on the command's Arg definitions.
|
|
24
21
|
*/
|
|
@@ -28,14 +25,14 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
|
28
25
|
const val = result[argDef.name];
|
|
29
26
|
// 1. Check required
|
|
30
27
|
if (argDef.required && (val === undefined || val === null || val === '')) {
|
|
31
|
-
throw new
|
|
28
|
+
throw new ArgumentError(`Argument "${argDef.name}" is required.`, argDef.help ?? `Provide a value for --${argDef.name}`);
|
|
32
29
|
}
|
|
33
30
|
if (val !== undefined && val !== null) {
|
|
34
31
|
// 2. Type coercion
|
|
35
32
|
if (argDef.type === 'int' || argDef.type === 'number') {
|
|
36
33
|
const num = Number(val);
|
|
37
34
|
if (Number.isNaN(num)) {
|
|
38
|
-
throw new
|
|
35
|
+
throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
|
39
36
|
}
|
|
40
37
|
result[argDef.name] = num;
|
|
41
38
|
}
|
|
@@ -47,7 +44,7 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
|
47
44
|
else if (lower === 'false' || lower === '0')
|
|
48
45
|
result[argDef.name] = false;
|
|
49
46
|
else
|
|
50
|
-
throw new
|
|
47
|
+
throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
|
51
48
|
}
|
|
52
49
|
else {
|
|
53
50
|
result[argDef.name] = Boolean(val);
|
|
@@ -57,7 +54,7 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
|
57
54
|
const coercedVal = result[argDef.name];
|
|
58
55
|
if (argDef.choices && argDef.choices.length > 0) {
|
|
59
56
|
if (!argDef.choices.map(String).includes(String(coercedVal))) {
|
|
60
|
-
throw new
|
|
57
|
+
throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
|
61
58
|
}
|
|
62
59
|
}
|
|
63
60
|
}
|
|
@@ -95,7 +92,7 @@ async function runCommand(cmd, page, kwargs, debug) {
|
|
|
95
92
|
return cmd.func(page, kwargs, debug);
|
|
96
93
|
if (cmd.pipeline)
|
|
97
94
|
return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
|
|
98
|
-
throw new
|
|
95
|
+
throw new CommandExecutionError(`Command ${fullName(cmd)} has no func or pipeline`, 'This is likely a bug in the adapter definition. Please report this issue.');
|
|
99
96
|
}
|
|
100
97
|
/**
|
|
101
98
|
* Resolve the pre-navigation URL for a command, or null to skip.
|
|
@@ -127,7 +124,9 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
127
124
|
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
|
128
125
|
}
|
|
129
126
|
catch (err) {
|
|
130
|
-
|
|
127
|
+
if (err instanceof ArgumentError)
|
|
128
|
+
throw err;
|
|
129
|
+
throw new ArgumentError(getErrorMessage(err));
|
|
131
130
|
}
|
|
132
131
|
if (shouldUseBrowserSession(cmd)) {
|
|
133
132
|
const BrowserFactory = getBrowserFactory();
|
package/dist/explore.js
CHANGED
|
@@ -12,6 +12,7 @@ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_
|
|
|
12
12
|
import { detectFramework } from './scripts/framework.js';
|
|
13
13
|
import { discoverStores } from './scripts/store.js';
|
|
14
14
|
import { interactFuzz } from './scripts/interact.js';
|
|
15
|
+
import { log } from './logger.js';
|
|
15
16
|
// ── Site name detection ────────────────────────────────────────────────────
|
|
16
17
|
const KNOWN_SITE_ALIASES = {
|
|
17
18
|
'x.com': 'twitter', 'twitter.com': 'twitter',
|
|
@@ -148,7 +149,8 @@ function flattenFields(obj, prefix, maxDepth) {
|
|
|
148
149
|
return names;
|
|
149
150
|
}
|
|
150
151
|
function isBooleanRecord(value) {
|
|
151
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
152
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
153
|
+
&& Object.values(value).every(v => typeof v === 'boolean');
|
|
152
154
|
}
|
|
153
155
|
function scoreEndpoint(ep) {
|
|
154
156
|
let s = 0;
|
|
@@ -360,7 +362,7 @@ export async function exploreUrl(url, opts) {
|
|
|
360
362
|
await page.wait(2); // wait for XHRs to settle
|
|
361
363
|
}
|
|
362
364
|
catch (e) {
|
|
363
|
-
|
|
365
|
+
log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
364
366
|
}
|
|
365
367
|
}
|
|
366
368
|
// Step 3: Read page metadata
|
package/dist/external.d.ts
CHANGED
|
@@ -15,6 +15,21 @@ export interface ExternalCliConfig {
|
|
|
15
15
|
export declare function loadExternalClis(): ExternalCliConfig[];
|
|
16
16
|
export declare function isBinaryInstalled(binary: string): boolean;
|
|
17
17
|
export declare function getInstallCmd(installConfig?: ExternalCliInstall): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Safely parses a command string into a binary and argument list.
|
|
20
|
+
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
|
21
|
+
* cannot be safely expressed as execFileSync arguments.
|
|
22
|
+
*
|
|
23
|
+
* Args:
|
|
24
|
+
* cmd: Raw command string from YAML config (e.g. "brew install gh")
|
|
25
|
+
*
|
|
26
|
+
* Returns:
|
|
27
|
+
* Object with `binary` and `args` fields, or throws on unsafe input.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseCommand(cmd: string): {
|
|
30
|
+
binary: string;
|
|
31
|
+
args: string[];
|
|
32
|
+
};
|
|
18
33
|
export declare function installExternalCli(cli: ExternalCliConfig): boolean;
|
|
19
34
|
export declare function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void;
|
|
20
35
|
export interface RegisterOptions {
|
package/dist/external.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { spawnSync,
|
|
5
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
6
6
|
import yaml from 'js-yaml';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { log } from './logger.js';
|
|
@@ -66,6 +66,52 @@ export function getInstallCmd(installConfig) {
|
|
|
66
66
|
return installConfig.default;
|
|
67
67
|
return null;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Safely parses a command string into a binary and argument list.
|
|
71
|
+
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
|
72
|
+
* cannot be safely expressed as execFileSync arguments.
|
|
73
|
+
*
|
|
74
|
+
* Args:
|
|
75
|
+
* cmd: Raw command string from YAML config (e.g. "brew install gh")
|
|
76
|
+
*
|
|
77
|
+
* Returns:
|
|
78
|
+
* Object with `binary` and `args` fields, or throws on unsafe input.
|
|
79
|
+
*/
|
|
80
|
+
export function parseCommand(cmd) {
|
|
81
|
+
const shellOperators = /&&|\|\|?|;|[><`]/;
|
|
82
|
+
if (shellOperators.test(cmd)) {
|
|
83
|
+
throw new Error(`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
|
|
84
|
+
`Please install the tool manually.`);
|
|
85
|
+
}
|
|
86
|
+
// Tokenise respecting single- and double-quoted segments (no variable expansion).
|
|
87
|
+
const tokens = [];
|
|
88
|
+
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
|
|
89
|
+
let match;
|
|
90
|
+
while ((match = re.exec(cmd)) !== null) {
|
|
91
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
92
|
+
}
|
|
93
|
+
if (tokens.length === 0) {
|
|
94
|
+
throw new Error(`Install command is empty.`);
|
|
95
|
+
}
|
|
96
|
+
const [binary, ...args] = tokens;
|
|
97
|
+
return { binary, args };
|
|
98
|
+
}
|
|
99
|
+
function shouldRetryWithCmdShim(binary, err) {
|
|
100
|
+
return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
|
|
101
|
+
}
|
|
102
|
+
function runInstallCommand(cmd) {
|
|
103
|
+
const { binary, args } = parseCommand(cmd);
|
|
104
|
+
try {
|
|
105
|
+
execFileSync(binary, args, { stdio: 'inherit' });
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (shouldRetryWithCmdShim(binary, err)) {
|
|
109
|
+
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
69
115
|
export function installExternalCli(cli) {
|
|
70
116
|
if (!cli.install) {
|
|
71
117
|
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
|
|
@@ -82,7 +128,7 @@ export function installExternalCli(cli) {
|
|
|
82
128
|
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
|
|
83
129
|
console.log(chalk.dim(`$ ${cmd}`));
|
|
84
130
|
try {
|
|
85
|
-
|
|
131
|
+
runInstallCommand(cmd);
|
|
86
132
|
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
|
|
87
133
|
return true;
|
|
88
134
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
|
|
3
|
+
mockExecFileSync: vi.fn(),
|
|
4
|
+
mockPlatform: vi.fn(() => 'darwin'),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('node:child_process', () => ({
|
|
7
|
+
spawnSync: vi.fn(),
|
|
8
|
+
execFileSync: mockExecFileSync,
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('node:os', async () => {
|
|
11
|
+
const actual = await vi.importActual('node:os');
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
platform: mockPlatform,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
import { installExternalCli, parseCommand } from './external.js';
|
|
18
|
+
describe('parseCommand', () => {
|
|
19
|
+
it('splits binaries and quoted arguments without invoking a shell', () => {
|
|
20
|
+
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
|
|
21
|
+
binary: 'npm',
|
|
22
|
+
args: ['install', '-g', '@scope/tool name'],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('rejects shell operators', () => {
|
|
26
|
+
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow('Install command contains unsafe shell operators');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('installExternalCli', () => {
|
|
30
|
+
const cli = {
|
|
31
|
+
name: 'readwise',
|
|
32
|
+
binary: 'readwise',
|
|
33
|
+
install: {
|
|
34
|
+
default: 'npm install -g @readwiseio/readwise-cli',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
mockExecFileSync.mockReset();
|
|
39
|
+
mockPlatform.mockReturnValue('darwin');
|
|
40
|
+
});
|
|
41
|
+
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
|
|
42
|
+
mockPlatform.mockReturnValue('win32');
|
|
43
|
+
mockExecFileSync
|
|
44
|
+
.mockImplementationOnce(() => {
|
|
45
|
+
const err = new Error('not found');
|
|
46
|
+
err.code = 'ENOENT';
|
|
47
|
+
throw err;
|
|
48
|
+
})
|
|
49
|
+
.mockReturnValueOnce(Buffer.from(''));
|
|
50
|
+
expect(installExternalCli(cli)).toBe(true);
|
|
51
|
+
expect(mockExecFileSync).toHaveBeenNthCalledWith(1, 'npm', ['install', '-g', '@readwiseio/readwise-cli'], { stdio: 'inherit' });
|
|
52
|
+
expect(mockExecFileSync).toHaveBeenNthCalledWith(2, 'npm.cmd', ['install', '-g', '@readwiseio/readwise-cli'], { stdio: 'inherit' });
|
|
53
|
+
});
|
|
54
|
+
it('does not mask non-ENOENT failures', () => {
|
|
55
|
+
mockPlatform.mockReturnValue('win32');
|
|
56
|
+
mockExecFileSync.mockImplementationOnce(() => {
|
|
57
|
+
const err = new Error('permission denied');
|
|
58
|
+
err.code = 'EACCES';
|
|
59
|
+
throw err;
|
|
60
|
+
});
|
|
61
|
+
expect(installExternalCli(cli)).toBe(false);
|
|
62
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/dist/main.js
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* opencli — Make any website your CLI. AI-powered.
|
|
4
4
|
*/
|
|
5
|
+
// Ensure standard system paths are available for child processes.
|
|
6
|
+
// Some environments (GUI apps, cron, IDE terminals) launch with a minimal PATH
|
|
7
|
+
// that excludes /usr/local/bin, /usr/sbin, etc., causing external CLIs to fail.
|
|
8
|
+
if (process.platform !== 'win32') {
|
|
9
|
+
const std = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
10
|
+
const cur = new Set((process.env.PATH ?? '').split(':').filter(Boolean));
|
|
11
|
+
for (const p of std)
|
|
12
|
+
cur.add(p);
|
|
13
|
+
process.env.PATH = [...cur].join(':');
|
|
14
|
+
}
|
|
5
15
|
import * as os from 'node:os';
|
|
6
16
|
import * as path from 'node:path';
|
|
7
17
|
import { fileURLToPath } from 'node:url';
|
package/dist/plugin.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export declare function installPlugin(source: string): string;
|
|
|
19
19
|
* Uninstall a plugin by name.
|
|
20
20
|
*/
|
|
21
21
|
export declare function uninstallPlugin(name: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Update a plugin by name (git pull + re-install lifecycle).
|
|
24
|
+
*/
|
|
25
|
+
export declare function updatePlugin(name: string): void;
|
|
22
26
|
/**
|
|
23
27
|
* List all installed plugins.
|
|
24
28
|
*/
|
package/dist/plugin.js
CHANGED
|
@@ -9,6 +9,30 @@ import * as path from 'node:path';
|
|
|
9
9
|
import { execSync, execFileSync } from 'node:child_process';
|
|
10
10
|
import { PLUGINS_DIR } from './discovery.js';
|
|
11
11
|
import { log } from './logger.js';
|
|
12
|
+
/**
|
|
13
|
+
* Shared post-install lifecycle: npm install → host symlink → TS transpile.
|
|
14
|
+
* Called by both installPlugin() and updatePlugin().
|
|
15
|
+
*/
|
|
16
|
+
function postInstallLifecycle(pluginDir) {
|
|
17
|
+
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
18
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('npm', ['install', '--omit=dev'], {
|
|
22
|
+
cwd: pluginDir,
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Non-fatal: npm install may fail if no real deps
|
|
29
|
+
}
|
|
30
|
+
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
|
|
31
|
+
// against the running host, not a stale npm-published version.
|
|
32
|
+
linkHostOpencli(pluginDir);
|
|
33
|
+
// Transpile .ts → .js via esbuild (production node can't load .ts directly).
|
|
34
|
+
transpilePluginTs(pluginDir);
|
|
35
|
+
}
|
|
12
36
|
/**
|
|
13
37
|
* Install a plugin from a source.
|
|
14
38
|
* Currently supports "github:user/repo" format (git clone wrapper).
|
|
@@ -37,29 +61,7 @@ export function installPlugin(source) {
|
|
|
37
61
|
catch (err) {
|
|
38
62
|
throw new Error(`Failed to clone plugin: ${err.message}`);
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
// then symlink the host opencli into node_modules for peerDep resolution.
|
|
42
|
-
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
43
|
-
if (fs.existsSync(pkgJsonPath)) {
|
|
44
|
-
try {
|
|
45
|
-
execFileSync('npm', ['install', '--omit=dev'], {
|
|
46
|
-
cwd: targetDir,
|
|
47
|
-
encoding: 'utf-8',
|
|
48
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// Non-fatal: npm install may fail if no real deps
|
|
53
|
-
}
|
|
54
|
-
// Symlink host opencli into plugin's node_modules so TS plugins
|
|
55
|
-
// can resolve '@jackwener/opencli/registry' against the running host.
|
|
56
|
-
// This is more reliable than depending on the npm-published version
|
|
57
|
-
// which may lag behind the local installation.
|
|
58
|
-
linkHostOpencli(targetDir);
|
|
59
|
-
// Transpile TS plugin files to JS so they work in production mode
|
|
60
|
-
// (node cannot load .ts files directly without tsx).
|
|
61
|
-
transpilePluginTs(targetDir);
|
|
62
|
-
}
|
|
64
|
+
postInstallLifecycle(targetDir);
|
|
63
65
|
return name;
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
@@ -72,6 +74,26 @@ export function uninstallPlugin(name) {
|
|
|
72
74
|
}
|
|
73
75
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Update a plugin by name (git pull + re-install lifecycle).
|
|
79
|
+
*/
|
|
80
|
+
export function updatePlugin(name) {
|
|
81
|
+
const targetDir = path.join(PLUGINS_DIR, name);
|
|
82
|
+
if (!fs.existsSync(targetDir)) {
|
|
83
|
+
throw new Error(`Plugin "${name}" is not installed.`);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
execFileSync('git', ['pull', '--ff-only'], {
|
|
87
|
+
cwd: targetDir,
|
|
88
|
+
encoding: 'utf-8',
|
|
89
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw new Error(`Failed to update plugin: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
postInstallLifecycle(targetDir);
|
|
96
|
+
}
|
|
75
97
|
/**
|
|
76
98
|
* List all installed plugins.
|
|
77
99
|
*/
|
package/dist/plugin.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import { PLUGINS_DIR } from './discovery.js';
|
|
8
|
-
import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js';
|
|
8
|
+
import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
|
|
9
9
|
describe('parseSource', () => {
|
|
10
10
|
it('parses github:user/repo format', () => {
|
|
11
11
|
const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
|
|
@@ -74,3 +74,8 @@ describe('uninstallPlugin', () => {
|
|
|
74
74
|
expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
describe('updatePlugin', () => {
|
|
78
|
+
it('throws for non-existent plugin', () => {
|
|
79
|
+
expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record mode — capture API calls from a live browser session.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Navigate to the target URL in an automation tab
|
|
6
|
+
* 2. Inject a full-capture fetch/XHR interceptor (records url + method + body)
|
|
7
|
+
* 3. Poll every 2s and print newly captured requests
|
|
8
|
+
* 4. User operates the page; press Enter to stop
|
|
9
|
+
* 5. Analyze captured requests → infer capabilities → write YAML candidates
|
|
10
|
+
*
|
|
11
|
+
* Design: no new daemon endpoints, no extension changes.
|
|
12
|
+
* Uses existing exec + navigate actions only.
|
|
13
|
+
*/
|
|
14
|
+
import type { IPage } from './types.js';
|
|
15
|
+
export interface RecordedRequest {
|
|
16
|
+
url: string;
|
|
17
|
+
method: string;
|
|
18
|
+
status: number | null;
|
|
19
|
+
contentType: string;
|
|
20
|
+
body: unknown;
|
|
21
|
+
capturedAt: number;
|
|
22
|
+
}
|
|
23
|
+
export interface RecordResult {
|
|
24
|
+
site: string;
|
|
25
|
+
url: string;
|
|
26
|
+
requests: RecordedRequest[];
|
|
27
|
+
outDir: string;
|
|
28
|
+
candidateCount: number;
|
|
29
|
+
candidates: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
strategy: string;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
export interface RecordOptions {
|
|
36
|
+
BrowserFactory: new () => {
|
|
37
|
+
connect(o?: unknown): Promise<IPage>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
site?: string;
|
|
41
|
+
url: string;
|
|
42
|
+
outDir?: string;
|
|
43
|
+
pollMs?: number;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function recordSession(opts: RecordOptions): Promise<RecordResult>;
|
|
47
|
+
export declare function renderRecordSummary(result: RecordResult): string;
|