@jackwener/opencli 1.5.0 → 1.5.1
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/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/cli-manifest.json +465 -5
- package/dist/cli.js +34 -3
- 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/v2ex/hot.yaml +17 -3
- 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/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +2 -2
- 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 -7
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -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/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/page.ts +24 -1
- package/src/cli.ts +34 -3
- 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/v2ex/hot.yaml +17 -3
- 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/discovery.ts +41 -33
- package/src/doctor.ts +2 -3
- 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 -6
- 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/tests/e2e/browser-public.test.ts +1 -1
package/src/engine.test.ts
CHANGED
|
@@ -83,9 +83,15 @@ cli({
|
|
|
83
83
|
describe('discoverPlugins', () => {
|
|
84
84
|
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
|
|
85
85
|
const yamlPath = path.join(testPluginDir, 'greeting.yaml');
|
|
86
|
+
const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
|
|
87
|
+
const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
|
|
88
|
+
const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
|
|
86
89
|
|
|
87
90
|
afterEach(async () => {
|
|
88
91
|
try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {}
|
|
92
|
+
try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {}
|
|
93
|
+
try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {}
|
|
94
|
+
try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {}
|
|
89
95
|
});
|
|
90
96
|
|
|
91
97
|
it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
|
|
@@ -118,6 +124,38 @@ columns: [message]
|
|
|
118
124
|
// discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
|
|
119
125
|
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
120
126
|
});
|
|
127
|
+
|
|
128
|
+
it('discovers YAML plugins from symlinked plugin directories', async () => {
|
|
129
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
130
|
+
await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
|
|
131
|
+
await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
|
|
132
|
+
site: __test-plugin-symlink__
|
|
133
|
+
name: hello
|
|
134
|
+
description: Test plugin greeting via symlink
|
|
135
|
+
strategy: public
|
|
136
|
+
browser: false
|
|
137
|
+
|
|
138
|
+
pipeline:
|
|
139
|
+
- evaluate: "() => [{ message: 'hello from symlink plugin' }]"
|
|
140
|
+
|
|
141
|
+
columns: [message]
|
|
142
|
+
`);
|
|
143
|
+
await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
|
|
144
|
+
|
|
145
|
+
await discoverPlugins();
|
|
146
|
+
|
|
147
|
+
const cmd = getRegistry().get('__test-plugin-symlink__/hello');
|
|
148
|
+
expect(cmd).toBeDefined();
|
|
149
|
+
expect(cmd!.description).toBe('Test plugin greeting via symlink');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips broken plugin symlinks without throwing', async () => {
|
|
153
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
154
|
+
await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
|
|
155
|
+
|
|
156
|
+
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
157
|
+
expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
|
|
158
|
+
});
|
|
121
159
|
});
|
|
122
160
|
|
|
123
161
|
describe('executeCommand', () => {
|
package/src/errors.ts
CHANGED
|
@@ -51,8 +51,12 @@ export class AuthRequiredError extends CliError {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export class TimeoutError extends CliError {
|
|
54
|
-
constructor(label: string, seconds: number) {
|
|
55
|
-
super(
|
|
54
|
+
constructor(label: string, seconds: number, hint?: string) {
|
|
55
|
+
super(
|
|
56
|
+
'TIMEOUT',
|
|
57
|
+
`${label} timed out after ${seconds}s`,
|
|
58
|
+
hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
|
|
59
|
+
);
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
7
|
+
describe('executeCommand — non-browser timeout', () => {
|
|
8
|
+
it('applies timeoutSeconds to non-browser commands', async () => {
|
|
9
|
+
const cmd = cli({
|
|
10
|
+
site: 'test-execution',
|
|
11
|
+
name: 'non-browser-timeout',
|
|
12
|
+
description: 'test non-browser timeout',
|
|
13
|
+
browser: false,
|
|
14
|
+
strategy: Strategy.PUBLIC,
|
|
15
|
+
timeoutSeconds: 0.01,
|
|
16
|
+
func: () => new Promise(() => {}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
|
|
20
|
+
// the error will be a TimeoutError with the command label, not 'sentinel'.
|
|
21
|
+
const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
|
|
22
|
+
.catch((err) => err);
|
|
23
|
+
|
|
24
|
+
expect(error).toBeInstanceOf(TimeoutError);
|
|
25
|
+
expect(error).toMatchObject({
|
|
26
|
+
code: 'TIMEOUT',
|
|
27
|
+
message: 'test-execution/non-browser-timeout timed out after 0.01s',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('skips timeout when timeoutSeconds is 0', async () => {
|
|
32
|
+
const cmd = cli({
|
|
33
|
+
site: 'test-execution',
|
|
34
|
+
name: 'non-browser-zero-timeout',
|
|
35
|
+
description: 'test zero timeout bypasses wrapping',
|
|
36
|
+
browser: false,
|
|
37
|
+
strategy: Strategy.PUBLIC,
|
|
38
|
+
timeoutSeconds: 0,
|
|
39
|
+
func: () => new Promise(() => {}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// With timeout guard skipped, the sentinel fires instead.
|
|
43
|
+
await expect(
|
|
44
|
+
withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'),
|
|
45
|
+
).rejects.toThrow('sentinel timeout');
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/execution.ts
CHANGED
|
@@ -130,6 +130,23 @@ function ensureRequiredEnv(cmd: CliCommand): void {
|
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Check if the browser is already on the target domain, avoiding redundant navigation.
|
|
135
|
+
* Returns true if current page hostname matches the pre-nav URL hostname.
|
|
136
|
+
*/
|
|
137
|
+
async function isAlreadyOnDomain(page: IPage, targetUrl: string): Promise<boolean> {
|
|
138
|
+
if (!page.getCurrentUrl) return false;
|
|
139
|
+
try {
|
|
140
|
+
const currentUrl = await page.getCurrentUrl();
|
|
141
|
+
if (!currentUrl) return false;
|
|
142
|
+
const currentHost = new URL(currentUrl).hostname;
|
|
143
|
+
const targetHost = new URL(targetUrl).hostname;
|
|
144
|
+
return currentHost === targetHost;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
export async function executeCommand(
|
|
134
151
|
cmd: CliCommand,
|
|
135
152
|
rawKwargs: CommandArgs,
|
|
@@ -181,11 +198,17 @@ export async function executeCommand(
|
|
|
181
198
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
182
199
|
const preNavUrl = resolvePreNav(cmd);
|
|
183
200
|
if (preNavUrl) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
201
|
+
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
|
202
|
+
if (skip) {
|
|
203
|
+
if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
|
|
204
|
+
} else {
|
|
205
|
+
try {
|
|
206
|
+
// goto() already includes smart DOM-settle detection (waitForDomStable).
|
|
207
|
+
// No additional fixed sleep needed.
|
|
208
|
+
await page.goto(preNavUrl);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
211
|
+
}
|
|
189
212
|
}
|
|
190
213
|
}
|
|
191
214
|
return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
|
|
@@ -194,7 +217,17 @@ export async function executeCommand(
|
|
|
194
217
|
});
|
|
195
218
|
}, { workspace: `site:${cmd.site}` });
|
|
196
219
|
} else {
|
|
197
|
-
|
|
220
|
+
// Non-browser commands: apply timeout only when explicitly configured.
|
|
221
|
+
const timeout = cmd.timeoutSeconds;
|
|
222
|
+
if (timeout !== undefined && timeout > 0) {
|
|
223
|
+
result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
|
|
224
|
+
timeout,
|
|
225
|
+
label: fullName(cmd),
|
|
226
|
+
hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
result = await runCommand(cmd, null, kwargs, debug);
|
|
230
|
+
}
|
|
198
231
|
}
|
|
199
232
|
} catch (err) {
|
|
200
233
|
hookCtx.error = err;
|
package/src/external.ts
CHANGED
|
@@ -31,7 +31,10 @@ function getUserRegistryPath(): string {
|
|
|
31
31
|
return path.join(home, '.opencli', 'external-clis.yaml');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
let _cachedExternalClis: ExternalCliConfig[] | null = null;
|
|
35
|
+
|
|
34
36
|
export function loadExternalClis(): ExternalCliConfig[] {
|
|
37
|
+
if (_cachedExternalClis) return _cachedExternalClis;
|
|
35
38
|
const configs = new Map<string, ExternalCliConfig>();
|
|
36
39
|
|
|
37
40
|
// 1. Load built-in
|
|
@@ -60,7 +63,8 @@ export function loadExternalClis(): ExternalCliConfig[] {
|
|
|
60
63
|
log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
_cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
67
|
+
return _cachedExternalClis;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
export function isBinaryInstalled(binary: string): boolean {
|
|
@@ -237,5 +241,6 @@ export function registerExternalCli(name: string, opts?: RegisterOptions): void
|
|
|
237
241
|
|
|
238
242
|
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
|
|
239
243
|
fs.writeFileSync(userPath, dump, 'utf8');
|
|
244
|
+
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
|
|
240
245
|
console.log(chalk.dim(userPath));
|
|
241
246
|
}
|
package/src/main.ts
CHANGED
|
@@ -27,6 +27,7 @@ const __dirname = path.dirname(__filename);
|
|
|
27
27
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|
|
28
28
|
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
|
29
29
|
|
|
30
|
+
// Sequential: plugins must run after built-in discovery so they can override built-in commands.
|
|
30
31
|
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
|
31
32
|
await discoverPlugins();
|
|
32
33
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plugin scaffold: create new plugin directories.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as os from 'node:os';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { createPluginScaffold } from './plugin-scaffold.js';
|
|
10
|
+
|
|
11
|
+
describe('createPluginScaffold', () => {
|
|
12
|
+
const createdDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const dir of createdDirs) {
|
|
16
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
17
|
+
}
|
|
18
|
+
createdDirs.length = 0;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('creates all expected files', () => {
|
|
22
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
23
|
+
createdDirs.push(dir);
|
|
24
|
+
|
|
25
|
+
const result = createPluginScaffold('my-test', { dir });
|
|
26
|
+
expect(result.name).toBe('my-test');
|
|
27
|
+
expect(result.dir).toBe(dir);
|
|
28
|
+
expect(result.files).toContain('opencli-plugin.json');
|
|
29
|
+
expect(result.files).toContain('package.json');
|
|
30
|
+
expect(result.files).toContain('hello.yaml');
|
|
31
|
+
expect(result.files).toContain('greet.ts');
|
|
32
|
+
expect(result.files).toContain('README.md');
|
|
33
|
+
|
|
34
|
+
// All files exist
|
|
35
|
+
for (const f of result.files) {
|
|
36
|
+
expect(fs.existsSync(path.join(dir, f))).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('generates valid opencli-plugin.json', () => {
|
|
41
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
42
|
+
createdDirs.push(dir);
|
|
43
|
+
|
|
44
|
+
createPluginScaffold('test-manifest', { dir, description: 'Test desc' });
|
|
45
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'opencli-plugin.json'), 'utf-8'));
|
|
46
|
+
expect(manifest.name).toBe('test-manifest');
|
|
47
|
+
expect(manifest.version).toBe('0.1.0');
|
|
48
|
+
expect(manifest.description).toBe('Test desc');
|
|
49
|
+
expect(manifest.opencli).toMatch(/^>=/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('generates ESM package.json', () => {
|
|
53
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
54
|
+
createdDirs.push(dir);
|
|
55
|
+
|
|
56
|
+
createPluginScaffold('test-pkg', { dir });
|
|
57
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
58
|
+
expect(pkg.type).toBe('module');
|
|
59
|
+
expect(pkg.peerDependencies?.['@jackwener/opencli']).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('generates a TS sample that matches the current plugin API', () => {
|
|
63
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
64
|
+
createdDirs.push(dir);
|
|
65
|
+
|
|
66
|
+
createPluginScaffold('test-ts', { dir });
|
|
67
|
+
const tsSample = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
|
|
68
|
+
|
|
69
|
+
expect(tsSample).toContain(`import { cli, Strategy } from '@jackwener/opencli/registry';`);
|
|
70
|
+
expect(tsSample).toContain(`strategy: Strategy.PUBLIC`);
|
|
71
|
+
expect(tsSample).toContain(`help: 'Name to greet'`);
|
|
72
|
+
expect(tsSample).toContain(`func: async (_page, kwargs)`);
|
|
73
|
+
expect(tsSample).not.toContain('async run(');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('documents a supported local install flow', () => {
|
|
77
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
78
|
+
createdDirs.push(dir);
|
|
79
|
+
|
|
80
|
+
createPluginScaffold('test-readme', { dir });
|
|
81
|
+
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
|
|
82
|
+
|
|
83
|
+
expect(readme).toContain(`opencli plugin install file://${dir}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects invalid names', () => {
|
|
87
|
+
expect(() => createPluginScaffold('Bad_Name')).toThrow('Invalid plugin name');
|
|
88
|
+
expect(() => createPluginScaffold('123start')).toThrow('Invalid plugin name');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects non-empty directory', () => {
|
|
92
|
+
const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
|
|
93
|
+
createdDirs.push(dir);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
fs.writeFileSync(path.join(dir, 'existing.txt'), 'x');
|
|
96
|
+
expect(() => createPluginScaffold('test', { dir })).toThrow('not empty');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { PKG_VERSION } from './version.js';
|
|
18
|
+
|
|
19
|
+
export interface ScaffoldOptions {
|
|
20
|
+
/** Directory to create the plugin in. Defaults to `./<name>` */
|
|
21
|
+
dir?: string;
|
|
22
|
+
/** Plugin description */
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScaffoldResult {
|
|
27
|
+
name: string;
|
|
28
|
+
dir: string;
|
|
29
|
+
files: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a new plugin scaffold directory.
|
|
34
|
+
*/
|
|
35
|
+
export function createPluginScaffold(name: string, opts: ScaffoldOptions = {}): ScaffoldResult {
|
|
36
|
+
// Validate name
|
|
37
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Invalid plugin name "${name}". ` +
|
|
40
|
+
`Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const targetDir = opts.dir
|
|
45
|
+
? path.resolve(opts.dir)
|
|
46
|
+
: path.resolve(name);
|
|
47
|
+
|
|
48
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
49
|
+
throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
|
|
56
|
+
// opencli-plugin.json
|
|
57
|
+
const manifest = {
|
|
58
|
+
name,
|
|
59
|
+
version: '0.1.0',
|
|
60
|
+
description: opts.description ?? `An opencli plugin: ${name}`,
|
|
61
|
+
opencli: `>=${PKG_VERSION}`,
|
|
62
|
+
};
|
|
63
|
+
writeFile(targetDir, 'opencli-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
64
|
+
files.push('opencli-plugin.json');
|
|
65
|
+
|
|
66
|
+
// package.json
|
|
67
|
+
const pkg = {
|
|
68
|
+
name: `opencli-plugin-${name}`,
|
|
69
|
+
version: '0.1.0',
|
|
70
|
+
type: 'module',
|
|
71
|
+
description: opts.description ?? `An opencli plugin: ${name}`,
|
|
72
|
+
peerDependencies: {
|
|
73
|
+
'@jackwener/opencli': `>=${PKG_VERSION}`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
writeFile(targetDir, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
77
|
+
files.push('package.json');
|
|
78
|
+
|
|
79
|
+
// hello.yaml — sample YAML command
|
|
80
|
+
const yamlContent = `# Sample YAML command for ${name}
|
|
81
|
+
# See: https://github.com/jackwener/opencli#yaml-commands
|
|
82
|
+
|
|
83
|
+
site: ${name}
|
|
84
|
+
name: hello
|
|
85
|
+
description: "A sample YAML command"
|
|
86
|
+
strategy: public
|
|
87
|
+
browser: false
|
|
88
|
+
|
|
89
|
+
domain: https://httpbin.org
|
|
90
|
+
|
|
91
|
+
pipeline:
|
|
92
|
+
- fetch:
|
|
93
|
+
url: "https://httpbin.org/get?greeting=hello"
|
|
94
|
+
method: GET
|
|
95
|
+
- extract:
|
|
96
|
+
type: json
|
|
97
|
+
selector: "$.args"
|
|
98
|
+
`;
|
|
99
|
+
writeFile(targetDir, 'hello.yaml', yamlContent);
|
|
100
|
+
files.push('hello.yaml');
|
|
101
|
+
|
|
102
|
+
// greet.ts — sample TS command using registry API
|
|
103
|
+
const tsContent = `/**
|
|
104
|
+
* Sample TypeScript command for ${name}.
|
|
105
|
+
* Demonstrates the programmatic cli() registration API.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
109
|
+
|
|
110
|
+
cli({
|
|
111
|
+
site: '${name}',
|
|
112
|
+
name: 'greet',
|
|
113
|
+
description: 'Greet someone by name',
|
|
114
|
+
strategy: Strategy.PUBLIC,
|
|
115
|
+
browser: false,
|
|
116
|
+
args: [
|
|
117
|
+
{ name: 'name', positional: true, required: true, help: 'Name to greet' },
|
|
118
|
+
],
|
|
119
|
+
columns: ['greeting'],
|
|
120
|
+
func: async (_page, kwargs) => [{ greeting: \`Hello, \${String(kwargs.name ?? 'World')}!\` }],
|
|
121
|
+
});
|
|
122
|
+
`;
|
|
123
|
+
writeFile(targetDir, 'greet.ts', tsContent);
|
|
124
|
+
files.push('greet.ts');
|
|
125
|
+
|
|
126
|
+
// README.md
|
|
127
|
+
const readme = `# opencli-plugin-${name}
|
|
128
|
+
|
|
129
|
+
${opts.description ?? `An opencli plugin: ${name}`}
|
|
130
|
+
|
|
131
|
+
## Install
|
|
132
|
+
|
|
133
|
+
\`\`\`bash
|
|
134
|
+
# From local development directory
|
|
135
|
+
opencli plugin install file://${targetDir}
|
|
136
|
+
|
|
137
|
+
# From GitHub (after publishing)
|
|
138
|
+
opencli plugin install github:<user>/opencli-plugin-${name}
|
|
139
|
+
\`\`\`
|
|
140
|
+
|
|
141
|
+
## Commands
|
|
142
|
+
|
|
143
|
+
| Command | Type | Description |
|
|
144
|
+
|---------|------|-------------|
|
|
145
|
+
| \`${name}/hello\` | YAML | Sample YAML command |
|
|
146
|
+
| \`${name}/greet\` | TypeScript | Sample TS command |
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
\`\`\`bash
|
|
151
|
+
# Install locally for development (symlinked, changes reflect immediately)
|
|
152
|
+
opencli plugin install file://${targetDir}
|
|
153
|
+
|
|
154
|
+
# Verify commands are registered
|
|
155
|
+
opencli list | grep ${name}
|
|
156
|
+
|
|
157
|
+
# Run a command
|
|
158
|
+
opencli ${name} hello
|
|
159
|
+
opencli ${name} greet --name World
|
|
160
|
+
\`\`\`
|
|
161
|
+
`;
|
|
162
|
+
writeFile(targetDir, 'README.md', readme);
|
|
163
|
+
files.push('README.md');
|
|
164
|
+
|
|
165
|
+
return { name, dir: targetDir, files };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function writeFile(dir: string, name: string, content: string): void {
|
|
169
|
+
fs.writeFileSync(path.join(dir, name), content);
|
|
170
|
+
}
|