@jackwener/opencli 1.5.0 → 1.5.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/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +52 -3
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +460 -1
- package/dist/cli.js +34 -3
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/daemon.js +1 -0
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +9 -5
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -13
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +49 -3
- package/src/browser.test.ts +6 -0
- package/src/cli.ts +34 -3
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/daemon.ts +1 -0
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +11 -8
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -15
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
package/dist/discovery.js
CHANGED
|
@@ -127,22 +127,20 @@ async function discoverClisFromFs(dir) {
|
|
|
127
127
|
const site = entry.name;
|
|
128
128
|
const siteDir = path.join(dir, site);
|
|
129
129
|
const files = await fs.promises.readdir(siteDir);
|
|
130
|
-
|
|
131
|
-
for (const file of files) {
|
|
130
|
+
await Promise.all(files.map(async (file) => {
|
|
132
131
|
const filePath = path.join(siteDir, file);
|
|
133
132
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
134
|
-
|
|
133
|
+
await registerYamlCli(filePath, site);
|
|
135
134
|
}
|
|
136
135
|
else if ((file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
|
137
136
|
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))) {
|
|
138
137
|
if (!(await isCliModule(filePath)))
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
return;
|
|
139
|
+
await import(pathToFileURL(filePath).href).catch((err) => {
|
|
141
140
|
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
|
|
142
|
-
})
|
|
141
|
+
});
|
|
143
142
|
}
|
|
144
|
-
}
|
|
145
|
-
await Promise.all(filePromises);
|
|
143
|
+
}));
|
|
146
144
|
});
|
|
147
145
|
await Promise.all(sitePromises);
|
|
148
146
|
}
|
|
@@ -194,11 +192,12 @@ export async function discoverPlugins() {
|
|
|
194
192
|
return;
|
|
195
193
|
}
|
|
196
194
|
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
await Promise.all(entries.map(async (entry) => {
|
|
196
|
+
const pluginDir = path.join(PLUGINS_DIR, entry.name);
|
|
197
|
+
if (!(await isDiscoverablePluginDir(entry, pluginDir)))
|
|
198
|
+
return;
|
|
199
|
+
await discoverPluginDir(pluginDir, entry.name);
|
|
200
|
+
}));
|
|
202
201
|
}
|
|
203
202
|
/**
|
|
204
203
|
* Flat scan: read yaml/ts files directly in a plugin directory.
|
|
@@ -207,32 +206,29 @@ export async function discoverPlugins() {
|
|
|
207
206
|
async function discoverPluginDir(dir, site) {
|
|
208
207
|
const files = await fs.promises.readdir(dir);
|
|
209
208
|
const fileSet = new Set(files);
|
|
210
|
-
|
|
211
|
-
for (const file of files) {
|
|
209
|
+
await Promise.all(files.map(async (file) => {
|
|
212
210
|
const filePath = path.join(dir, file);
|
|
213
211
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
214
|
-
|
|
212
|
+
await registerYamlCli(filePath, site);
|
|
215
213
|
}
|
|
216
214
|
else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
|
|
217
215
|
if (!(await isCliModule(filePath)))
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
return;
|
|
217
|
+
await import(pathToFileURL(filePath).href).catch((err) => {
|
|
220
218
|
log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
|
|
221
|
-
})
|
|
219
|
+
});
|
|
222
220
|
}
|
|
223
221
|
else if (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) {
|
|
224
|
-
// Skip .ts if a compiled .js sibling exists (production mode can't load .ts)
|
|
225
222
|
const jsFile = file.replace(/\.ts$/, '.js');
|
|
223
|
+
// Prefer compiled .js — skip the .ts source file
|
|
226
224
|
if (fileSet.has(jsFile))
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}));
|
|
225
|
+
return;
|
|
226
|
+
// No compiled .js found — cannot import raw .ts in production Node.js.
|
|
227
|
+
// This typically means esbuild transpilation failed during plugin install.
|
|
228
|
+
log.warn(`Plugin ${site}/${file}: no compiled .js found. ` +
|
|
229
|
+
`Run "opencli plugin update ${site}" to re-transpile, or install esbuild.`);
|
|
233
230
|
}
|
|
234
|
-
}
|
|
235
|
-
await Promise.all(promises);
|
|
231
|
+
}));
|
|
236
232
|
}
|
|
237
233
|
async function isCliModule(filePath) {
|
|
238
234
|
try {
|
|
@@ -244,3 +240,19 @@ async function isCliModule(filePath) {
|
|
|
244
240
|
return false;
|
|
245
241
|
}
|
|
246
242
|
}
|
|
243
|
+
async function isDiscoverablePluginDir(entry, pluginDir) {
|
|
244
|
+
if (entry.isDirectory())
|
|
245
|
+
return true;
|
|
246
|
+
if (!entry.isSymbolicLink())
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
return (await fs.promises.stat(pluginDir)).isDirectory();
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const code = err.code;
|
|
253
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
254
|
+
log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`);
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencli doctor — diagnose
|
|
2
|
+
* opencli doctor — diagnose browser connectivity.
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture. No more token management,
|
|
5
5
|
* MCP path discovery, or config file scanning.
|
|
6
6
|
*/
|
|
7
7
|
export type DoctorOptions = {
|
|
8
|
-
fix?: boolean;
|
|
9
8
|
yes?: boolean;
|
|
10
9
|
live?: boolean;
|
|
11
10
|
sessions?: boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencli doctor — diagnose
|
|
2
|
+
* opencli doctor — diagnose browser connectivity.
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture. No more token management,
|
|
5
5
|
* MCP path discovery, or config file scanning.
|
|
@@ -58,16 +58,20 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
58
58
|
if (status.running && !status.extensionConnected) {
|
|
59
59
|
issues.push('Daemon is running but the Chrome extension is not connected.\n' +
|
|
60
60
|
'Please install the opencli Browser Bridge extension:\n' +
|
|
61
|
-
' 1. Download from
|
|
61
|
+
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
|
62
62
|
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
63
63
|
' 3. Click "Load unpacked" → select the extension folder');
|
|
64
64
|
}
|
|
65
65
|
if (connectivity && !connectivity.ok) {
|
|
66
66
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
67
67
|
}
|
|
68
|
-
if (status.extensionVersion && opts.cliVersion
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (status.extensionVersion && opts.cliVersion) {
|
|
69
|
+
const extMajor = status.extensionVersion.split('.')[0];
|
|
70
|
+
const cliMajor = opts.cliVersion.split('.')[0];
|
|
71
|
+
if (extMajor !== cliMajor) {
|
|
72
|
+
issues.push(`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
73
|
+
' Download the latest extension from: https://github.com/jackwener/opencli/releases');
|
|
74
|
+
}
|
|
71
75
|
}
|
|
72
76
|
return {
|
|
73
77
|
cliVersion: opts.cliVersion,
|
package/dist/engine.test.js
CHANGED
|
@@ -75,11 +75,26 @@ cli({
|
|
|
75
75
|
describe('discoverPlugins', () => {
|
|
76
76
|
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
|
|
77
77
|
const yamlPath = path.join(testPluginDir, 'greeting.yaml');
|
|
78
|
+
const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
|
|
79
|
+
const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
|
|
80
|
+
const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
|
|
78
81
|
afterEach(async () => {
|
|
79
82
|
try {
|
|
80
83
|
await fs.promises.rm(testPluginDir, { recursive: true });
|
|
81
84
|
}
|
|
82
85
|
catch { }
|
|
86
|
+
try {
|
|
87
|
+
await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
try {
|
|
91
|
+
await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
try {
|
|
95
|
+
await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
83
98
|
});
|
|
84
99
|
it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
|
|
85
100
|
// Create a simple YAML adapter in the plugins directory
|
|
@@ -108,6 +123,33 @@ columns: [message]
|
|
|
108
123
|
// discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
|
|
109
124
|
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
110
125
|
});
|
|
126
|
+
it('discovers YAML plugins from symlinked plugin directories', async () => {
|
|
127
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
128
|
+
await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
|
|
129
|
+
await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
|
|
130
|
+
site: __test-plugin-symlink__
|
|
131
|
+
name: hello
|
|
132
|
+
description: Test plugin greeting via symlink
|
|
133
|
+
strategy: public
|
|
134
|
+
browser: false
|
|
135
|
+
|
|
136
|
+
pipeline:
|
|
137
|
+
- evaluate: "() => [{ message: 'hello from symlink plugin' }]"
|
|
138
|
+
|
|
139
|
+
columns: [message]
|
|
140
|
+
`);
|
|
141
|
+
await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
|
|
142
|
+
await discoverPlugins();
|
|
143
|
+
const cmd = getRegistry().get('__test-plugin-symlink__/hello');
|
|
144
|
+
expect(cmd).toBeDefined();
|
|
145
|
+
expect(cmd.description).toBe('Test plugin greeting via symlink');
|
|
146
|
+
});
|
|
147
|
+
it('skips broken plugin symlinks without throwing', async () => {
|
|
148
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
149
|
+
await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
|
|
150
|
+
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
151
|
+
expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
|
|
152
|
+
});
|
|
111
153
|
});
|
|
112
154
|
describe('executeCommand', () => {
|
|
113
155
|
beforeEach(() => {
|
package/dist/errors.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare class AuthRequiredError extends CliError {
|
|
|
31
31
|
constructor(domain: string, message?: string);
|
|
32
32
|
}
|
|
33
33
|
export declare class TimeoutError extends CliError {
|
|
34
|
-
constructor(label: string, seconds: number);
|
|
34
|
+
constructor(label: string, seconds: number, hint?: string);
|
|
35
35
|
}
|
|
36
36
|
export declare class ArgumentError extends CliError {
|
|
37
37
|
constructor(message: string, hint?: string);
|
package/dist/errors.js
CHANGED
|
@@ -41,8 +41,8 @@ export class AuthRequiredError extends CliError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
export class TimeoutError extends CliError {
|
|
44
|
-
constructor(label, seconds) {
|
|
45
|
-
super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
|
|
44
|
+
constructor(label, seconds, hint) {
|
|
45
|
+
super('TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
export class ArgumentError extends CliError {
|
package/dist/execution.js
CHANGED
|
@@ -17,8 +17,6 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
|
17
17
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
18
18
|
import { emitHook } from './hooks.js';
|
|
19
19
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
20
|
-
import { PKG_VERSION } from './version.js';
|
|
21
|
-
import chalk from 'chalk';
|
|
22
20
|
const _loadedModules = new Set();
|
|
23
21
|
export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
24
22
|
const result = { ...kwargs };
|
|
@@ -110,6 +108,25 @@ function ensureRequiredEnv(cmd) {
|
|
|
110
108
|
return;
|
|
111
109
|
throw new CommandExecutionError(`Command ${fullName(cmd)} requires environment variable ${missing.name}.`, missing.help ?? `Set ${missing.name} before running ${fullName(cmd)}.`);
|
|
112
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if the browser is already on the target domain, avoiding redundant navigation.
|
|
113
|
+
* Returns true if current page hostname matches the pre-nav URL hostname.
|
|
114
|
+
*/
|
|
115
|
+
async function isAlreadyOnDomain(page, targetUrl) {
|
|
116
|
+
if (!page.getCurrentUrl)
|
|
117
|
+
return false;
|
|
118
|
+
try {
|
|
119
|
+
const currentUrl = await page.getCurrentUrl();
|
|
120
|
+
if (!currentUrl)
|
|
121
|
+
return false;
|
|
122
|
+
const currentHost = new URL(currentUrl).hostname;
|
|
123
|
+
const targetHost = new URL(targetUrl).hostname;
|
|
124
|
+
return currentHost === targetHost;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
113
130
|
export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
114
131
|
let kwargs;
|
|
115
132
|
try {
|
|
@@ -142,22 +159,26 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
142
159
|
' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
143
160
|
' Then run: opencli doctor');
|
|
144
161
|
}
|
|
145
|
-
// ── Version mismatch: warn but don't block ──
|
|
146
|
-
if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
|
|
147
|
-
process.stderr.write(chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`));
|
|
148
|
-
}
|
|
149
162
|
ensureRequiredEnv(cmd);
|
|
150
163
|
const BrowserFactory = getBrowserFactory();
|
|
151
164
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
152
165
|
const preNavUrl = resolvePreNav(cmd);
|
|
153
166
|
if (preNavUrl) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.wait(2);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
167
|
+
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
|
168
|
+
if (skip) {
|
|
159
169
|
if (debug)
|
|
160
|
-
console.error(`[pre-nav]
|
|
170
|
+
console.error(`[pre-nav] Already on target domain, skipping navigation`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
try {
|
|
174
|
+
// goto() already includes smart DOM-settle detection (waitForDomStable).
|
|
175
|
+
// No additional fixed sleep needed.
|
|
176
|
+
await page.goto(preNavUrl);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (debug)
|
|
180
|
+
console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
181
|
+
}
|
|
161
182
|
}
|
|
162
183
|
}
|
|
163
184
|
return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
|
|
@@ -167,7 +188,18 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
167
188
|
}, { workspace: `site:${cmd.site}` });
|
|
168
189
|
}
|
|
169
190
|
else {
|
|
170
|
-
|
|
191
|
+
// Non-browser commands: apply timeout only when explicitly configured.
|
|
192
|
+
const timeout = cmd.timeoutSeconds;
|
|
193
|
+
if (timeout !== undefined && timeout > 0) {
|
|
194
|
+
result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
|
|
195
|
+
timeout,
|
|
196
|
+
label: fullName(cmd),
|
|
197
|
+
hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
result = await runCommand(cmd, null, kwargs, debug);
|
|
202
|
+
}
|
|
171
203
|
}
|
|
172
204
|
}
|
|
173
205
|
catch (err) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { executeCommand } from './execution.js';
|
|
3
|
+
import { TimeoutError } from './errors.js';
|
|
4
|
+
import { cli, Strategy } from './registry.js';
|
|
5
|
+
import { withTimeoutMs } from './runtime.js';
|
|
6
|
+
describe('executeCommand — non-browser timeout', () => {
|
|
7
|
+
it('applies timeoutSeconds to non-browser commands', async () => {
|
|
8
|
+
const cmd = cli({
|
|
9
|
+
site: 'test-execution',
|
|
10
|
+
name: 'non-browser-timeout',
|
|
11
|
+
description: 'test non-browser timeout',
|
|
12
|
+
browser: false,
|
|
13
|
+
strategy: Strategy.PUBLIC,
|
|
14
|
+
timeoutSeconds: 0.01,
|
|
15
|
+
func: () => new Promise(() => { }),
|
|
16
|
+
});
|
|
17
|
+
// Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
|
|
18
|
+
// the error will be a TimeoutError with the command label, not 'sentinel'.
|
|
19
|
+
const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
|
|
20
|
+
.catch((err) => err);
|
|
21
|
+
expect(error).toBeInstanceOf(TimeoutError);
|
|
22
|
+
expect(error).toMatchObject({
|
|
23
|
+
code: 'TIMEOUT',
|
|
24
|
+
message: 'test-execution/non-browser-timeout timed out after 0.01s',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it('skips timeout when timeoutSeconds is 0', async () => {
|
|
28
|
+
const cmd = cli({
|
|
29
|
+
site: 'test-execution',
|
|
30
|
+
name: 'non-browser-zero-timeout',
|
|
31
|
+
description: 'test zero timeout bypasses wrapping',
|
|
32
|
+
browser: false,
|
|
33
|
+
strategy: Strategy.PUBLIC,
|
|
34
|
+
timeoutSeconds: 0,
|
|
35
|
+
func: () => new Promise(() => { }),
|
|
36
|
+
});
|
|
37
|
+
// With timeout guard skipped, the sentinel fires instead.
|
|
38
|
+
await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
describe('extension manifest regression', () => {
|
|
5
|
+
it('keeps host permissions required by chrome.cookies.getAll', async () => {
|
|
6
|
+
const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
|
|
7
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
8
|
+
const manifest = JSON.parse(raw);
|
|
9
|
+
expect(manifest.permissions).toContain('cookies');
|
|
10
|
+
expect(manifest.host_permissions).toContain('<all_urls>');
|
|
11
|
+
});
|
|
12
|
+
});
|
package/dist/external.js
CHANGED
|
@@ -12,7 +12,10 @@ function getUserRegistryPath() {
|
|
|
12
12
|
const home = os.homedir();
|
|
13
13
|
return path.join(home, '.opencli', 'external-clis.yaml');
|
|
14
14
|
}
|
|
15
|
+
let _cachedExternalClis = null;
|
|
15
16
|
export function loadExternalClis() {
|
|
17
|
+
if (_cachedExternalClis)
|
|
18
|
+
return _cachedExternalClis;
|
|
16
19
|
const configs = new Map();
|
|
17
20
|
// 1. Load built-in
|
|
18
21
|
const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
|
|
@@ -41,7 +44,8 @@ export function loadExternalClis() {
|
|
|
41
44
|
catch (err) {
|
|
42
45
|
log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
|
|
43
46
|
}
|
|
44
|
-
|
|
47
|
+
_cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
return _cachedExternalClis;
|
|
45
49
|
}
|
|
46
50
|
export function isBinaryInstalled(binary) {
|
|
47
51
|
try {
|
|
@@ -200,5 +204,6 @@ export function registerExternalCli(name, opts) {
|
|
|
200
204
|
}
|
|
201
205
|
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
|
|
202
206
|
fs.writeFileSync(userPath, dump, 'utf8');
|
|
207
|
+
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
|
|
203
208
|
console.log(chalk.dim(userPath));
|
|
204
209
|
}
|
package/dist/main.js
CHANGED
|
@@ -24,6 +24,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
24
24
|
const __dirname = path.dirname(__filename);
|
|
25
25
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|
|
26
26
|
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
|
27
|
+
// Sequential: plugins must run after built-in discovery so they can override built-in commands.
|
|
27
28
|
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
|
28
29
|
await discoverPlugins();
|
|
29
30
|
// Register exit hook: notice appears after command output (same as npm/gh/yarn)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin scaffold: generates a ready-to-develop plugin directory.
|
|
3
|
+
*
|
|
4
|
+
* Usage: opencli plugin create <name> [--dir <path>]
|
|
5
|
+
*
|
|
6
|
+
* Creates:
|
|
7
|
+
* <name>/
|
|
8
|
+
* opencli-plugin.json — manifest with name, version, description
|
|
9
|
+
* package.json — ESM package with opencli peer dependency
|
|
10
|
+
* hello.yaml — sample YAML command
|
|
11
|
+
* greet.ts — sample TS command using the current registry API
|
|
12
|
+
* README.md — basic documentation
|
|
13
|
+
*/
|
|
14
|
+
export interface ScaffoldOptions {
|
|
15
|
+
/** Directory to create the plugin in. Defaults to `./<name>` */
|
|
16
|
+
dir?: string;
|
|
17
|
+
/** Plugin description */
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ScaffoldResult {
|
|
21
|
+
name: string;
|
|
22
|
+
dir: string;
|
|
23
|
+
files: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a new plugin scaffold directory.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createPluginScaffold(name: string, opts?: ScaffoldOptions): ScaffoldResult;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin scaffold: generates a ready-to-develop plugin directory.
|
|
3
|
+
*
|
|
4
|
+
* Usage: opencli plugin create <name> [--dir <path>]
|
|
5
|
+
*
|
|
6
|
+
* Creates:
|
|
7
|
+
* <name>/
|
|
8
|
+
* opencli-plugin.json — manifest with name, version, description
|
|
9
|
+
* package.json — ESM package with opencli peer dependency
|
|
10
|
+
* hello.yaml — sample YAML command
|
|
11
|
+
* greet.ts — sample TS command using the current registry API
|
|
12
|
+
* README.md — basic documentation
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { PKG_VERSION } from './version.js';
|
|
17
|
+
/**
|
|
18
|
+
* Create a new plugin scaffold directory.
|
|
19
|
+
*/
|
|
20
|
+
export function createPluginScaffold(name, opts = {}) {
|
|
21
|
+
// Validate name
|
|
22
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
23
|
+
throw new Error(`Invalid plugin name "${name}". ` +
|
|
24
|
+
`Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`);
|
|
25
|
+
}
|
|
26
|
+
const targetDir = opts.dir
|
|
27
|
+
? path.resolve(opts.dir)
|
|
28
|
+
: path.resolve(name);
|
|
29
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
30
|
+
throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
|
|
31
|
+
}
|
|
32
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
33
|
+
const files = [];
|
|
34
|
+
// opencli-plugin.json
|
|
35
|
+
const manifest = {
|
|
36
|
+
name,
|
|
37
|
+
version: '0.1.0',
|
|
38
|
+
description: opts.description ?? `An opencli plugin: ${name}`,
|
|
39
|
+
opencli: `>=${PKG_VERSION}`,
|
|
40
|
+
};
|
|
41
|
+
writeFile(targetDir, 'opencli-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
42
|
+
files.push('opencli-plugin.json');
|
|
43
|
+
// package.json
|
|
44
|
+
const pkg = {
|
|
45
|
+
name: `opencli-plugin-${name}`,
|
|
46
|
+
version: '0.1.0',
|
|
47
|
+
type: 'module',
|
|
48
|
+
description: opts.description ?? `An opencli plugin: ${name}`,
|
|
49
|
+
peerDependencies: {
|
|
50
|
+
'@jackwener/opencli': `>=${PKG_VERSION}`,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
writeFile(targetDir, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
54
|
+
files.push('package.json');
|
|
55
|
+
// hello.yaml — sample YAML command
|
|
56
|
+
const yamlContent = `# Sample YAML command for ${name}
|
|
57
|
+
# See: https://github.com/jackwener/opencli#yaml-commands
|
|
58
|
+
|
|
59
|
+
site: ${name}
|
|
60
|
+
name: hello
|
|
61
|
+
description: "A sample YAML command"
|
|
62
|
+
strategy: public
|
|
63
|
+
browser: false
|
|
64
|
+
|
|
65
|
+
domain: https://httpbin.org
|
|
66
|
+
|
|
67
|
+
pipeline:
|
|
68
|
+
- fetch:
|
|
69
|
+
url: "https://httpbin.org/get?greeting=hello"
|
|
70
|
+
method: GET
|
|
71
|
+
- extract:
|
|
72
|
+
type: json
|
|
73
|
+
selector: "$.args"
|
|
74
|
+
`;
|
|
75
|
+
writeFile(targetDir, 'hello.yaml', yamlContent);
|
|
76
|
+
files.push('hello.yaml');
|
|
77
|
+
// greet.ts — sample TS command using registry API
|
|
78
|
+
const tsContent = `/**
|
|
79
|
+
* Sample TypeScript command for ${name}.
|
|
80
|
+
* Demonstrates the programmatic cli() registration API.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
84
|
+
|
|
85
|
+
cli({
|
|
86
|
+
site: '${name}',
|
|
87
|
+
name: 'greet',
|
|
88
|
+
description: 'Greet someone by name',
|
|
89
|
+
strategy: Strategy.PUBLIC,
|
|
90
|
+
browser: false,
|
|
91
|
+
args: [
|
|
92
|
+
{ name: 'name', positional: true, required: true, help: 'Name to greet' },
|
|
93
|
+
],
|
|
94
|
+
columns: ['greeting'],
|
|
95
|
+
func: async (_page, kwargs) => [{ greeting: \`Hello, \${String(kwargs.name ?? 'World')}!\` }],
|
|
96
|
+
});
|
|
97
|
+
`;
|
|
98
|
+
writeFile(targetDir, 'greet.ts', tsContent);
|
|
99
|
+
files.push('greet.ts');
|
|
100
|
+
// README.md
|
|
101
|
+
const readme = `# opencli-plugin-${name}
|
|
102
|
+
|
|
103
|
+
${opts.description ?? `An opencli plugin: ${name}`}
|
|
104
|
+
|
|
105
|
+
## Install
|
|
106
|
+
|
|
107
|
+
\`\`\`bash
|
|
108
|
+
# From local development directory
|
|
109
|
+
opencli plugin install file://${targetDir}
|
|
110
|
+
|
|
111
|
+
# From GitHub (after publishing)
|
|
112
|
+
opencli plugin install github:<user>/opencli-plugin-${name}
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
| Command | Type | Description |
|
|
118
|
+
|---------|------|-------------|
|
|
119
|
+
| \`${name}/hello\` | YAML | Sample YAML command |
|
|
120
|
+
| \`${name}/greet\` | TypeScript | Sample TS command |
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
\`\`\`bash
|
|
125
|
+
# Install locally for development (symlinked, changes reflect immediately)
|
|
126
|
+
opencli plugin install file://${targetDir}
|
|
127
|
+
|
|
128
|
+
# Verify commands are registered
|
|
129
|
+
opencli list | grep ${name}
|
|
130
|
+
|
|
131
|
+
# Run a command
|
|
132
|
+
opencli ${name} hello
|
|
133
|
+
opencli ${name} greet --name World
|
|
134
|
+
\`\`\`
|
|
135
|
+
`;
|
|
136
|
+
writeFile(targetDir, 'README.md', readme);
|
|
137
|
+
files.push('README.md');
|
|
138
|
+
return { name, dir: targetDir, files };
|
|
139
|
+
}
|
|
140
|
+
function writeFile(dir, name, content) {
|
|
141
|
+
fs.writeFileSync(path.join(dir, name), content);
|
|
142
|
+
}
|