@jackwener/opencli 1.3.0 → 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
- package/.github/workflows/release-please.yml +0 -25
package/src/errors.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Unified error types for opencli.
|
|
3
3
|
*
|
|
4
4
|
* All errors thrown by the framework should extend CliError so that
|
|
5
|
-
* the top-level handler in
|
|
5
|
+
* the top-level handler in commanderAdapter.ts can render consistent,
|
|
6
|
+
* helpful output with emoji-coded severity and actionable hints.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export class CliError extends Error {
|
|
9
|
-
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', '
|
|
10
|
+
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
|
|
10
11
|
readonly code: string;
|
|
11
12
|
/** Human-readable hint on how to fix the problem */
|
|
12
13
|
readonly hint?: string;
|
|
@@ -19,6 +20,8 @@ export class CliError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// ── Browser / Connection ────────────────────────────────────────────────────
|
|
24
|
+
|
|
22
25
|
export class BrowserConnectError extends CliError {
|
|
23
26
|
constructor(message: string, hint?: string) {
|
|
24
27
|
super('BROWSER_CONNECT', message, hint);
|
|
@@ -26,6 +29,8 @@ export class BrowserConnectError extends CliError {
|
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
// ── Adapter loading ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
29
34
|
export class AdapterLoadError extends CliError {
|
|
30
35
|
constructor(message: string, hint?: string) {
|
|
31
36
|
super('ADAPTER_LOAD', message, hint);
|
|
@@ -33,6 +38,8 @@ export class AdapterLoadError extends CliError {
|
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// ── Command execution ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
36
43
|
export class CommandExecutionError extends CliError {
|
|
37
44
|
constructor(message: string, hint?: string) {
|
|
38
45
|
super('COMMAND_EXEC', message, hint);
|
|
@@ -40,9 +47,92 @@ export class CommandExecutionError extends CliError {
|
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
43
52
|
export class ConfigError extends CliError {
|
|
44
53
|
constructor(message: string, hint?: string) {
|
|
45
54
|
super('CONFIG', message, hint);
|
|
46
55
|
this.name = 'ConfigError';
|
|
47
56
|
}
|
|
48
57
|
}
|
|
58
|
+
|
|
59
|
+
// ── Authentication / Login ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export class AuthRequiredError extends CliError {
|
|
62
|
+
readonly domain: string;
|
|
63
|
+
|
|
64
|
+
constructor(domain: string, message?: string) {
|
|
65
|
+
super(
|
|
66
|
+
'AUTH_REQUIRED',
|
|
67
|
+
message ?? `Not logged in to ${domain}`,
|
|
68
|
+
`Please open Chrome and log in to https://${domain}`,
|
|
69
|
+
);
|
|
70
|
+
this.name = 'AuthRequiredError';
|
|
71
|
+
this.domain = domain;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Timeout ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export class TimeoutError extends CliError {
|
|
78
|
+
constructor(label: string, seconds: number) {
|
|
79
|
+
super(
|
|
80
|
+
'TIMEOUT',
|
|
81
|
+
`${label} timed out after ${seconds}s`,
|
|
82
|
+
'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
|
|
83
|
+
);
|
|
84
|
+
this.name = 'TimeoutError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Argument validation ─────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export class ArgumentError extends CliError {
|
|
91
|
+
constructor(message: string, hint?: string) {
|
|
92
|
+
super('ARGUMENT', message, hint);
|
|
93
|
+
this.name = 'ArgumentError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Empty result ────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export class EmptyResultError extends CliError {
|
|
100
|
+
constructor(command: string, hint?: string) {
|
|
101
|
+
super(
|
|
102
|
+
'EMPTY_RESULT',
|
|
103
|
+
`${command} returned no data`,
|
|
104
|
+
hint ?? 'The page structure may have changed, or you may need to log in',
|
|
105
|
+
);
|
|
106
|
+
this.name = 'EmptyResultError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Selector / DOM ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export class SelectorError extends CliError {
|
|
113
|
+
constructor(selector: string, hint?: string) {
|
|
114
|
+
super(
|
|
115
|
+
'SELECTOR',
|
|
116
|
+
`Could not find element: ${selector}`,
|
|
117
|
+
hint ?? 'The page UI may have changed. Please report this issue.',
|
|
118
|
+
);
|
|
119
|
+
this.name = 'SelectorError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Utilities ───────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** Extract a human-readable message from an unknown caught value. */
|
|
126
|
+
export function getErrorMessage(error: unknown): string {
|
|
127
|
+
return error instanceof Error ? error.message : String(error);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Error code → emoji mapping for CLI output rendering. */
|
|
131
|
+
export const ERROR_ICONS: Record<string, string> = {
|
|
132
|
+
AUTH_REQUIRED: '🔒',
|
|
133
|
+
BROWSER_CONNECT: '🔌',
|
|
134
|
+
TIMEOUT: '⏱ ',
|
|
135
|
+
ARGUMENT: '❌',
|
|
136
|
+
EMPTY_RESULT: '📭',
|
|
137
|
+
SELECTOR: '🔍',
|
|
138
|
+
};
|
package/src/execution.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegist
|
|
|
13
13
|
import type { IPage } from './types.js';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { executePipeline } from './pipeline/index.js';
|
|
16
|
-
import { AdapterLoadError } from './errors.js';
|
|
16
|
+
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
|
|
17
17
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
18
18
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
19
19
|
|
|
@@ -21,9 +21,6 @@ import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMM
|
|
|
21
21
|
const _loadedModules = new Set<string>();
|
|
22
22
|
type CommandArgs = Record<string, unknown>;
|
|
23
23
|
|
|
24
|
-
function getErrorMessage(error: unknown): string {
|
|
25
|
-
return error instanceof Error ? error.message : String(error);
|
|
26
|
-
}
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* Validates and coerces arguments based on the command's Arg definitions.
|
|
@@ -36,7 +33,10 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
|
|
36
33
|
|
|
37
34
|
// 1. Check required
|
|
38
35
|
if (argDef.required && (val === undefined || val === null || val === '')) {
|
|
39
|
-
throw new
|
|
36
|
+
throw new ArgumentError(
|
|
37
|
+
`Argument "${argDef.name}" is required.`,
|
|
38
|
+
argDef.help ?? `Provide a value for --${argDef.name}`,
|
|
39
|
+
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (val !== undefined && val !== null) {
|
|
@@ -44,7 +44,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
|
|
44
44
|
if (argDef.type === 'int' || argDef.type === 'number') {
|
|
45
45
|
const num = Number(val);
|
|
46
46
|
if (Number.isNaN(num)) {
|
|
47
|
-
throw new
|
|
47
|
+
throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
|
48
48
|
}
|
|
49
49
|
result[argDef.name] = num;
|
|
50
50
|
} else if (argDef.type === 'boolean' || argDef.type === 'bool') {
|
|
@@ -52,7 +52,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
|
|
52
52
|
const lower = val.toLowerCase();
|
|
53
53
|
if (lower === 'true' || lower === '1') result[argDef.name] = true;
|
|
54
54
|
else if (lower === 'false' || lower === '0') result[argDef.name] = false;
|
|
55
|
-
else throw new
|
|
55
|
+
else throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
|
56
56
|
} else {
|
|
57
57
|
result[argDef.name] = Boolean(val);
|
|
58
58
|
}
|
|
@@ -62,7 +62,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
|
|
62
62
|
const coercedVal = result[argDef.name];
|
|
63
63
|
if (argDef.choices && argDef.choices.length > 0) {
|
|
64
64
|
if (!argDef.choices.map(String).includes(String(coercedVal))) {
|
|
65
|
-
throw new
|
|
65
|
+
throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
} else if (argDef.default !== undefined) {
|
|
@@ -104,7 +104,10 @@ async function runCommand(
|
|
|
104
104
|
|
|
105
105
|
if (cmd.func) return cmd.func(page!, kwargs, debug);
|
|
106
106
|
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
|
|
107
|
-
throw new
|
|
107
|
+
throw new CommandExecutionError(
|
|
108
|
+
`Command ${fullName(cmd)} has no func or pipeline`,
|
|
109
|
+
'This is likely a bug in the adapter definition. Please report this issue.',
|
|
110
|
+
);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
@@ -140,7 +143,8 @@ export async function executeCommand(
|
|
|
140
143
|
try {
|
|
141
144
|
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
|
142
145
|
} catch (err) {
|
|
143
|
-
|
|
146
|
+
if (err instanceof ArgumentError) throw err;
|
|
147
|
+
throw new ArgumentError(getErrorMessage(err));
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
if (shouldUseBrowserSession(cmd)) {
|
package/src/explore.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { detectFramework } from './scripts/framework.js';
|
|
|
15
15
|
import { discoverStores } from './scripts/store.js';
|
|
16
16
|
import { interactFuzz } from './scripts/interact.js';
|
|
17
17
|
import type { IPage } from './types.js';
|
|
18
|
+
import { log } from './logger.js';
|
|
18
19
|
|
|
19
20
|
// ── Site name detection ────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -224,7 +225,8 @@ function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[]
|
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
|
227
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
228
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
229
|
+
&& Object.values(value as Record<string, unknown>).every(v => typeof v === 'boolean');
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
function scoreEndpoint(ep: { contentType: string; responseAnalysis: AnalyzedEndpoint['responseAnalysis']; pattern: string; status: number | null; hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean }): number {
|
|
@@ -453,7 +455,7 @@ export async function exploreUrl(
|
|
|
453
455
|
const clicks = await page.evaluate(INTERACT_FUZZ_JS);
|
|
454
456
|
await page.wait(2); // wait for XHRs to settle
|
|
455
457
|
} catch (e) {
|
|
456
|
-
|
|
458
|
+
log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
457
459
|
}
|
|
458
460
|
}
|
|
459
461
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
|
|
4
|
+
mockExecFileSync: vi.fn(),
|
|
5
|
+
mockPlatform: vi.fn(() => 'darwin'),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('node:child_process', () => ({
|
|
9
|
+
spawnSync: vi.fn(),
|
|
10
|
+
execFileSync: mockExecFileSync,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('node:os', async () => {
|
|
14
|
+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
platform: mockPlatform,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
|
|
22
|
+
|
|
23
|
+
describe('parseCommand', () => {
|
|
24
|
+
it('splits binaries and quoted arguments without invoking a shell', () => {
|
|
25
|
+
expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
|
|
26
|
+
binary: 'npm',
|
|
27
|
+
args: ['install', '-g', '@scope/tool name'],
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects shell operators', () => {
|
|
32
|
+
expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
|
|
33
|
+
'Install command contains unsafe shell operators',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('installExternalCli', () => {
|
|
39
|
+
const cli: ExternalCliConfig = {
|
|
40
|
+
name: 'readwise',
|
|
41
|
+
binary: 'readwise',
|
|
42
|
+
install: {
|
|
43
|
+
default: 'npm install -g @readwiseio/readwise-cli',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
mockExecFileSync.mockReset();
|
|
49
|
+
mockPlatform.mockReturnValue('darwin');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('retries with .cmd on Windows when the bare binary is unavailable', () => {
|
|
53
|
+
mockPlatform.mockReturnValue('win32');
|
|
54
|
+
mockExecFileSync
|
|
55
|
+
.mockImplementationOnce(() => {
|
|
56
|
+
const err = new Error('not found') as NodeJS.ErrnoException;
|
|
57
|
+
err.code = 'ENOENT';
|
|
58
|
+
throw err;
|
|
59
|
+
})
|
|
60
|
+
.mockReturnValueOnce(Buffer.from(''));
|
|
61
|
+
|
|
62
|
+
expect(installExternalCli(cli)).toBe(true);
|
|
63
|
+
expect(mockExecFileSync).toHaveBeenNthCalledWith(
|
|
64
|
+
1,
|
|
65
|
+
'npm',
|
|
66
|
+
['install', '-g', '@readwiseio/readwise-cli'],
|
|
67
|
+
{ stdio: 'inherit' },
|
|
68
|
+
);
|
|
69
|
+
expect(mockExecFileSync).toHaveBeenNthCalledWith(
|
|
70
|
+
2,
|
|
71
|
+
'npm.cmd',
|
|
72
|
+
['install', '-g', '@readwiseio/readwise-cli'],
|
|
73
|
+
{ stdio: 'inherit' },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does not mask non-ENOENT failures', () => {
|
|
78
|
+
mockPlatform.mockReturnValue('win32');
|
|
79
|
+
mockExecFileSync.mockImplementationOnce(() => {
|
|
80
|
+
const err = new Error('permission denied') as NodeJS.ErrnoException;
|
|
81
|
+
err.code = 'EACCES';
|
|
82
|
+
throw err;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(installExternalCli(cli)).toBe(false);
|
|
86
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/external.ts
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';
|
|
@@ -82,6 +82,60 @@ export function getInstallCmd(installConfig?: ExternalCliInstall): string | null
|
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Safely parses a command string into a binary and argument list.
|
|
87
|
+
* Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
|
|
88
|
+
* cannot be safely expressed as execFileSync arguments.
|
|
89
|
+
*
|
|
90
|
+
* Args:
|
|
91
|
+
* cmd: Raw command string from YAML config (e.g. "brew install gh")
|
|
92
|
+
*
|
|
93
|
+
* Returns:
|
|
94
|
+
* Object with `binary` and `args` fields, or throws on unsafe input.
|
|
95
|
+
*/
|
|
96
|
+
export function parseCommand(cmd: string): { binary: string; args: string[] } {
|
|
97
|
+
const shellOperators = /&&|\|\|?|;|[><`]/;
|
|
98
|
+
if (shellOperators.test(cmd)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
|
|
101
|
+
`Please install the tool manually.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tokenise respecting single- and double-quoted segments (no variable expansion).
|
|
106
|
+
const tokens: string[] = [];
|
|
107
|
+
const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
|
|
108
|
+
let match: RegExpExecArray | null;
|
|
109
|
+
while ((match = re.exec(cmd)) !== null) {
|
|
110
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (tokens.length === 0) {
|
|
114
|
+
throw new Error(`Install command is empty.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [binary, ...args] = tokens;
|
|
118
|
+
return { binary, args };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shouldRetryWithCmdShim(binary: string, err: NodeJS.ErrnoException): boolean {
|
|
122
|
+
return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runInstallCommand(cmd: string): void {
|
|
126
|
+
const { binary, args } = parseCommand(cmd);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
execFileSync(binary, args, { stdio: 'inherit' });
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
if (shouldRetryWithCmdShim(binary, err)) {
|
|
132
|
+
execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
85
139
|
export function installExternalCli(cli: ExternalCliConfig): boolean {
|
|
86
140
|
if (!cli.install) {
|
|
87
141
|
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
|
|
@@ -99,7 +153,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
|
|
|
99
153
|
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
|
|
100
154
|
console.log(chalk.dim(`$ ${cmd}`));
|
|
101
155
|
try {
|
|
102
|
-
|
|
156
|
+
runInstallCommand(cmd);
|
|
103
157
|
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
|
|
104
158
|
return true;
|
|
105
159
|
} catch (err: any) {
|
package/src/generate.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { exploreUrl } from './explore.js';
|
|
|
12
12
|
import type { IBrowserFactory } from './runtime.js';
|
|
13
13
|
import { synthesizeFromExplore, type SynthesizeCandidateSummary, type SynthesizeResult } from './synthesize.js';
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// Registration is a no-op stub — candidates are written to disk by synthesize,
|
|
16
|
+
// but not yet auto-copied into the user clis dir.
|
|
16
17
|
interface RegisterCandidatesOptions {
|
|
17
18
|
target: string;
|
|
18
19
|
builtinClis?: string;
|
package/src/main.ts
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* opencli — Make any website your CLI. AI-powered.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// Ensure standard system paths are available for child processes.
|
|
7
|
+
// Some environments (GUI apps, cron, IDE terminals) launch with a minimal PATH
|
|
8
|
+
// that excludes /usr/local/bin, /usr/sbin, etc., causing external CLIs to fail.
|
|
9
|
+
if (process.platform !== 'win32') {
|
|
10
|
+
const std = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
11
|
+
const cur = new Set((process.env.PATH ?? '').split(':').filter(Boolean));
|
|
12
|
+
for (const p of std) cur.add(p);
|
|
13
|
+
process.env.PATH = [...cur].join(':');
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
import * as os from 'node:os';
|
|
7
17
|
import * as path from 'node:path';
|
|
8
18
|
import { fileURLToPath } from 'node:url';
|
package/src/plugin.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import { PLUGINS_DIR } from './discovery.js';
|
|
9
|
-
import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js';
|
|
9
|
+
import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
|
|
10
10
|
|
|
11
11
|
describe('parseSource', () => {
|
|
12
12
|
it('parses github:user/repo format', () => {
|
|
@@ -84,3 +84,9 @@ describe('uninstallPlugin', () => {
|
|
|
84
84
|
expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
describe('updatePlugin', () => {
|
|
89
|
+
it('throws for non-existent plugin', () => {
|
|
90
|
+
expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/plugin.ts
CHANGED
|
@@ -18,6 +18,32 @@ export interface PluginInfo {
|
|
|
18
18
|
source?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Shared post-install lifecycle: npm install → host symlink → TS transpile.
|
|
23
|
+
* Called by both installPlugin() and updatePlugin().
|
|
24
|
+
*/
|
|
25
|
+
function postInstallLifecycle(pluginDir: string): void {
|
|
26
|
+
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
27
|
+
if (!fs.existsSync(pkgJsonPath)) return;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
execFileSync('npm', ['install', '--omit=dev'], {
|
|
31
|
+
cwd: pluginDir,
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
// Non-fatal: npm install may fail if no real deps
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
|
|
40
|
+
// against the running host, not a stale npm-published version.
|
|
41
|
+
linkHostOpencli(pluginDir);
|
|
42
|
+
|
|
43
|
+
// Transpile .ts → .js via esbuild (production node can't load .ts directly).
|
|
44
|
+
transpilePluginTs(pluginDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
/**
|
|
22
48
|
* Install a plugin from a source.
|
|
23
49
|
* Currently supports "github:user/repo" format (git clone wrapper).
|
|
@@ -52,31 +78,7 @@ export function installPlugin(source: string): string {
|
|
|
52
78
|
throw new Error(`Failed to clone plugin: ${err.message}`);
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
|
|
56
|
-
// then symlink the host opencli into node_modules for peerDep resolution.
|
|
57
|
-
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
58
|
-
if (fs.existsSync(pkgJsonPath)) {
|
|
59
|
-
try {
|
|
60
|
-
execFileSync('npm', ['install', '--omit=dev'], {
|
|
61
|
-
cwd: targetDir,
|
|
62
|
-
encoding: 'utf-8',
|
|
63
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
-
});
|
|
65
|
-
} catch {
|
|
66
|
-
// Non-fatal: npm install may fail if no real deps
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Symlink host opencli into plugin's node_modules so TS plugins
|
|
70
|
-
// can resolve '@jackwener/opencli/registry' against the running host.
|
|
71
|
-
// This is more reliable than depending on the npm-published version
|
|
72
|
-
// which may lag behind the local installation.
|
|
73
|
-
linkHostOpencli(targetDir);
|
|
74
|
-
|
|
75
|
-
// Transpile TS plugin files to JS so they work in production mode
|
|
76
|
-
// (node cannot load .ts files directly without tsx).
|
|
77
|
-
transpilePluginTs(targetDir);
|
|
78
|
-
}
|
|
79
|
-
|
|
81
|
+
postInstallLifecycle(targetDir);
|
|
80
82
|
return name;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -91,6 +93,28 @@ export function uninstallPlugin(name: string): void {
|
|
|
91
93
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Update a plugin by name (git pull + re-install lifecycle).
|
|
98
|
+
*/
|
|
99
|
+
export function updatePlugin(name: string): void {
|
|
100
|
+
const targetDir = path.join(PLUGINS_DIR, name);
|
|
101
|
+
if (!fs.existsSync(targetDir)) {
|
|
102
|
+
throw new Error(`Plugin "${name}" is not installed.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('git', ['pull', '--ff-only'], {
|
|
107
|
+
cwd: targetDir,
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
110
|
+
});
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
throw new Error(`Failed to update plugin: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
postInstallLifecycle(targetDir);
|
|
116
|
+
}
|
|
117
|
+
|
|
94
118
|
/**
|
|
95
119
|
* List all installed plugins.
|
|
96
120
|
*/
|