@openlearn/plugin-sdk 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +411 -0
- package/package.json +32 -23
- package/scaffold/templates/frontend-only/package.json +22 -0
- package/scaffold/templates/frontend-only/src/frontend.tsx +21 -0
- package/scaffold/templates/frontend-only/src/index.ts +32 -0
- package/scaffold/templates/frontend-only/tsconfig.json +15 -0
- package/scaffold/templates/full-stack/package.json +22 -0
- package/scaffold/templates/full-stack/src/frontend.tsx +21 -0
- package/scaffold/templates/full-stack/src/index.ts +111 -0
- package/scaffold/templates/full-stack/tsconfig.json +15 -0
- package/scaffold/templates/server-only/package.json +17 -0
- package/scaffold/templates/server-only/src/index.ts +70 -0
- package/scaffold/templates/server-only/tsconfig.json +14 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @openlearn/plugin-sdk CLI — plugin scaffolding & build tool.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* init Scaffold a new OpenLearn plugin project
|
|
8
|
+
* build Bundle plugin source into a distributable ZIP
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* npx @openlearn/plugin-sdk init # interactive
|
|
12
|
+
* npx @openlearn/plugin-sdk init --name my-app # non-interactive
|
|
13
|
+
* npx @openlearn/plugin-sdk build # build from cwd
|
|
14
|
+
* npx @openlearn/plugin-sdk build --watch # watch mode
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, statSync, rmSync } from 'node:fs';
|
|
18
|
+
import { join, dirname, relative, resolve, basename } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
21
|
+
import { createInterface } from 'node:readline';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SCAFFOLD_DIR = join(__dirname, 'scaffold', 'templates');
|
|
26
|
+
|
|
27
|
+
// ── Color helpers ────────────────────────────────────────────────────────
|
|
28
|
+
const c = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[32m', cyan: '\x1b[36m', yellow: '\x1b[33m', red: '\x1b[31m' };
|
|
29
|
+
const tick = `${c.green}✔${c.reset}`;
|
|
30
|
+
const info = `${c.cyan}ℹ${c.reset}`;
|
|
31
|
+
const warn = `${c.yellow}⚠${c.reset}`;
|
|
32
|
+
|
|
33
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function sv(name) {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
|
37
|
+
return pkg.version;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function copyDir(src, dest) {
|
|
41
|
+
mkdirSync(dest, { recursive: true });
|
|
42
|
+
for (const entry of readdirSync(src)) {
|
|
43
|
+
const srcPath = join(src, entry);
|
|
44
|
+
const destPath = join(dest, entry);
|
|
45
|
+
if (statSync(srcPath).isDirectory()) {
|
|
46
|
+
copyDir(srcPath, destPath);
|
|
47
|
+
} else {
|
|
48
|
+
copyFileSync(srcPath, destPath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function replaceInFile(filePath, vars) {
|
|
54
|
+
let content = readFileSync(filePath, 'utf8');
|
|
55
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
56
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
57
|
+
}
|
|
58
|
+
writeFileSync(filePath, content, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function replaceInDir(dir, vars) {
|
|
62
|
+
for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
|
|
63
|
+
if (entry.isFile()) {
|
|
64
|
+
const fp = join(entry.parentPath || dir, entry.name);
|
|
65
|
+
replaceInFile(fp, vars);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function ask(rl, question) {
|
|
71
|
+
return new Promise(resolve => {
|
|
72
|
+
rl.question(`${c.cyan}?${c.reset} ${question} `, answer => resolve(answer.trim()));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Commands ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async function cmdInit(args) {
|
|
79
|
+
let name = '', description = '', author = '', template = 'server-only';
|
|
80
|
+
|
|
81
|
+
// Parse non-interactive args
|
|
82
|
+
for (let i = 0; i < args.length; i++) {
|
|
83
|
+
if (args[i] === '--name' && args[i + 1]) name = args[++i];
|
|
84
|
+
else if (args[i] === '--description' && args[i + 1]) description = args[++i];
|
|
85
|
+
else if (args[i] === '--author' && args[i + 1]) author = args[++i];
|
|
86
|
+
else if (args[i] === '--template' && args[i + 1]) template = args[++i];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const interactive = !name; // if --name is provided, skip interactive
|
|
90
|
+
|
|
91
|
+
if (interactive) {
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
93
|
+
|
|
94
|
+
console.log(`\n${c.bold}OpenLearn Plugin Scaffolder${c.reset} v${sv()}\n`);
|
|
95
|
+
|
|
96
|
+
name = await ask(rl, 'Plugin package name (kebab-case):');
|
|
97
|
+
if (!name) { console.error(`${c.red}Error:${c.reset} name is required`); process.exit(1); }
|
|
98
|
+
|
|
99
|
+
description = await ask(rl, `Description (default: "${name} plugin"):`);
|
|
100
|
+
if (!description) description = `${name} plugin`;
|
|
101
|
+
|
|
102
|
+
author = await ask(rl, `Author (default: "OpenLearn Developer"):`);
|
|
103
|
+
if (!author) author = 'OpenLearn Developer';
|
|
104
|
+
|
|
105
|
+
console.log(`\n${c.dim}Available templates:${c.reset}`);
|
|
106
|
+
console.log(` ${c.bold}server-only${c.reset} — Backend plugin (commands, events, AI tools)`);
|
|
107
|
+
console.log(` ${c.bold}full-stack${c.reset} — Full plugin (server + React frontend)`);
|
|
108
|
+
console.log(` ${c.bold}frontend-only${c.reset} — Pure UI extension (React component)`);
|
|
109
|
+
|
|
110
|
+
template = await ask(rl, `\nTemplate (default: server-only):`);
|
|
111
|
+
if (!template) template = 'server-only';
|
|
112
|
+
|
|
113
|
+
rl.close();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!['server-only', 'full-stack', 'frontend-only'].includes(template)) {
|
|
117
|
+
console.error(`${c.red}Error:${c.reset} unknown template "${template}". Use: server-only, full-stack, frontend-only`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Validate name
|
|
122
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
123
|
+
console.error(`${c.red}Error:${c.reset} name must be lowercase alphanumeric with hyphens, starting with a letter`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const targetDir = resolve(process.cwd(), name);
|
|
128
|
+
if (existsSync(targetDir)) {
|
|
129
|
+
console.error(`${c.red}Error:${c.reset} directory "${name}" already exists`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const srcDir = join(SCAFFOLD_DIR, template);
|
|
134
|
+
if (!existsSync(srcDir)) {
|
|
135
|
+
console.error(`${c.red}Error:${c.reset} template "${template}" not found at ${srcDir}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Scaffold
|
|
140
|
+
console.log(`\n${info} Scaffolding "${name}" with template "${template}"...`);
|
|
141
|
+
|
|
142
|
+
copyDir(srcDir, targetDir);
|
|
143
|
+
|
|
144
|
+
const pluginId = `@${author.toLowerCase().replace(/[^a-z0-9]/g, '-')}/${name}`;
|
|
145
|
+
const sdkVersion = sv();
|
|
146
|
+
const componentName = name.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('');
|
|
147
|
+
|
|
148
|
+
const vars = {
|
|
149
|
+
name,
|
|
150
|
+
description,
|
|
151
|
+
author,
|
|
152
|
+
pluginId,
|
|
153
|
+
pluginName: name.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join(' '),
|
|
154
|
+
sdkVersion,
|
|
155
|
+
componentName,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
replaceInDir(targetDir, vars);
|
|
159
|
+
|
|
160
|
+
// Rename frontend.tsx if template has it
|
|
161
|
+
const feFile = join(targetDir, 'src', 'frontend.tsx');
|
|
162
|
+
if (existsSync(feFile)) {
|
|
163
|
+
replaceInFile(feFile, vars);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log(`\n ${tick} Created ${name}/`);
|
|
167
|
+
listDir(targetDir, 2);
|
|
168
|
+
|
|
169
|
+
console.log(`\n${c.bold}Next steps:${c.reset}`);
|
|
170
|
+
console.log(` cd ${name}`);
|
|
171
|
+
console.log(` npm install`);
|
|
172
|
+
console.log(` openlearn-plugin-sdk build`);
|
|
173
|
+
console.log(`\nThen upload ${name}.zip in OpenLearn Plugin Center.\n`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function listDir(dir, depth, prefix = '') {
|
|
177
|
+
const entries = readdirSync(dir).filter(e => e !== 'node_modules');
|
|
178
|
+
for (const entry of entries.slice(0, 12)) {
|
|
179
|
+
const fp = join(dir, entry);
|
|
180
|
+
const isDir = statSync(fp).isDirectory();
|
|
181
|
+
console.log(` ${prefix}${isDir ? '📁' : '📄'} ${entry}${isDir ? '/' : ''}`);
|
|
182
|
+
if (isDir && depth > 0) {
|
|
183
|
+
listDir(fp, depth - 1, prefix + ' ');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (entries.length > 12) console.log(` ${prefix}... and ${entries.length - 12} more`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function cmdBuild(args) {
|
|
190
|
+
const cwd = process.cwd();
|
|
191
|
+
const pkgPath = join(cwd, 'package.json');
|
|
192
|
+
|
|
193
|
+
if (!existsSync(pkgPath)) {
|
|
194
|
+
console.error(`${c.red}Error:${c.reset} package.json not found. Run this command in your plugin project root.`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
199
|
+
const pluginName = pkg.name || basename(cwd);
|
|
200
|
+
|
|
201
|
+
const srcDir = join(cwd, 'src');
|
|
202
|
+
const distDir = join(cwd, 'dist');
|
|
203
|
+
const indexEntry = join(srcDir, 'index.ts');
|
|
204
|
+
const frontendEntry = join(srcDir, 'frontend.tsx');
|
|
205
|
+
|
|
206
|
+
if (!existsSync(indexEntry)) {
|
|
207
|
+
console.error(`${c.red}Error:${c.reset} src/index.ts not found. Each plugin needs a server entry point.`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const watchMode = args.includes('--watch') || args.includes('-w');
|
|
212
|
+
|
|
213
|
+
if (!watchMode) {
|
|
214
|
+
rmSync(distDir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(distDir, { recursive: true });
|
|
217
|
+
|
|
218
|
+
const sdkDist = join(__dirname, 'dist', 'index.js');
|
|
219
|
+
|
|
220
|
+
console.log(`${info} Building ${pluginName}...`);
|
|
221
|
+
|
|
222
|
+
const buildOpts = (entry, outfile, external = []) => ({
|
|
223
|
+
entryPoints: [entry],
|
|
224
|
+
bundle: true,
|
|
225
|
+
write: true,
|
|
226
|
+
format: 'esm',
|
|
227
|
+
platform: 'node',
|
|
228
|
+
sourcemap: 'inline',
|
|
229
|
+
target: 'node18',
|
|
230
|
+
outfile,
|
|
231
|
+
external: ['@openlearn/plugin-sdk', ...external],
|
|
232
|
+
plugins: [{
|
|
233
|
+
name: 'resolve-plugin-sdk',
|
|
234
|
+
setup(build) {
|
|
235
|
+
build.onResolve({ filter: /^@openlearn\/plugin-sdk$/ }, () => ({
|
|
236
|
+
path: sdkDist,
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
}],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Load esbuild (try package-local first, then plugin-sdk bundled)
|
|
243
|
+
let esbuild;
|
|
244
|
+
try {
|
|
245
|
+
esbuild = (await import('esbuild')).default;
|
|
246
|
+
} catch {
|
|
247
|
+
try {
|
|
248
|
+
const projectEsbuild = join(cwd, 'node_modules', 'esbuild', 'lib', 'main.js');
|
|
249
|
+
esbuild = (await import(projectEsbuild)).default;
|
|
250
|
+
} catch {
|
|
251
|
+
console.error(`${c.red}Error:${c.reset} esbuild not found. Install it: npm install --save-dev esbuild`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Load JSZip
|
|
257
|
+
let JSZip;
|
|
258
|
+
try {
|
|
259
|
+
JSZip = (await import('jszip')).default;
|
|
260
|
+
} catch {
|
|
261
|
+
try {
|
|
262
|
+
JSZip = (await import(join(cwd, 'node_modules', 'jszip', 'lib', 'index.js'))).default;
|
|
263
|
+
} catch {
|
|
264
|
+
console.error(`${c.red}Error:${c.reset} jszip not found. Install it: npm install --save-dev jszip`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
if (watchMode) {
|
|
271
|
+
const ctx = await esbuild.context(buildOpts(indexEntry, join(distDir, 'index.js')));
|
|
272
|
+
await ctx.watch();
|
|
273
|
+
console.log(`${tick} Watching server entry...`);
|
|
274
|
+
if (existsSync(frontendEntry)) {
|
|
275
|
+
const feCtx = await esbuild.context({
|
|
276
|
+
...buildOpts(frontendEntry, join(distDir, 'frontend.js'), ['react', 'react-dom', 'recharts', 'lucide-react']),
|
|
277
|
+
platform: 'browser',
|
|
278
|
+
target: 'es2020',
|
|
279
|
+
});
|
|
280
|
+
await feCtx.watch();
|
|
281
|
+
console.log(`${tick} Watching frontend entry...`);
|
|
282
|
+
}
|
|
283
|
+
console.log(`${c.green}Build watch started.${c.reset} Press Ctrl+C to stop.`);
|
|
284
|
+
await new Promise(() => {});
|
|
285
|
+
} else {
|
|
286
|
+
// Build server
|
|
287
|
+
await esbuild.build(buildOpts(indexEntry, join(distDir, 'index.js')));
|
|
288
|
+
|
|
289
|
+
let hasFrontend = false;
|
|
290
|
+
if (existsSync(frontendEntry)) {
|
|
291
|
+
await esbuild.build({
|
|
292
|
+
...buildOpts(frontendEntry, join(distDir, 'frontend.js'), ['react', 'react-dom', 'recharts', 'lucide-react']),
|
|
293
|
+
platform: 'browser',
|
|
294
|
+
target: 'es2020',
|
|
295
|
+
});
|
|
296
|
+
hasFrontend = true;
|
|
297
|
+
}
|
|
298
|
+
console.log(`${tick} Bundled server${hasFrontend ? ' + frontend' : ''}`);
|
|
299
|
+
|
|
300
|
+
// Extract or load manifest
|
|
301
|
+
const manifestPath = join(cwd, 'manifest.json');
|
|
302
|
+
let manifest;
|
|
303
|
+
if (existsSync(manifestPath)) {
|
|
304
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
305
|
+
} else {
|
|
306
|
+
// Brace-aware manifest extraction from built code
|
|
307
|
+
const builtCode = readFileSync(join(distDir, 'index.js'), 'utf8');
|
|
308
|
+
const m = builtCode.match(/manifest:\s*/);
|
|
309
|
+
if (m) {
|
|
310
|
+
const start = m.index + m[0].length;
|
|
311
|
+
if (builtCode[start] === '{') {
|
|
312
|
+
let depth = 0, inString = false, escaped = false;
|
|
313
|
+
for (let i = start; i < builtCode.length; i++) {
|
|
314
|
+
const ch = builtCode[i];
|
|
315
|
+
if (escaped) { escaped = false; continue; }
|
|
316
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
317
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
318
|
+
if (inString) continue;
|
|
319
|
+
if (ch === '{') depth++;
|
|
320
|
+
else if (ch === '}') {
|
|
321
|
+
depth--;
|
|
322
|
+
if (depth === 0) {
|
|
323
|
+
try {
|
|
324
|
+
// esbuild preserves JS object syntax (unquoted keys), not JSON
|
|
325
|
+
// Use Function constructor to evaluate safely
|
|
326
|
+
manifest = (new Function('return ' + builtCode.substring(start, i + 1)))();
|
|
327
|
+
} catch (_) {}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (!manifest) {
|
|
335
|
+
console.warn(` ${warn} Could not extract manifest. Create a manifest.json file.`);
|
|
336
|
+
manifest = { id: pluginName, name: pluginName, version: '0.1.0' };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Package ZIP
|
|
341
|
+
const zip = new JSZip();
|
|
342
|
+
zip.file('index.js', readFileSync(join(distDir, 'index.js'), 'utf8'));
|
|
343
|
+
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
|
344
|
+
if (hasFrontend) {
|
|
345
|
+
zip.file('frontend.js', readFileSync(join(distDir, 'frontend.js'), 'utf8'));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const zipName = `${pluginName.replace(/\//g, '-').replace(/@/g, '')}.zip`;
|
|
349
|
+
const zipPath = join(distDir, zipName);
|
|
350
|
+
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
|
|
351
|
+
writeFileSync(zipPath, zipBuffer);
|
|
352
|
+
|
|
353
|
+
const cwdZip = join(cwd, zipName);
|
|
354
|
+
writeFileSync(cwdZip, zipBuffer);
|
|
355
|
+
|
|
356
|
+
const sizeKB = (zipBuffer.length / 1024).toFixed(1);
|
|
357
|
+
console.log(`${tick} Packaged ${zipName} (${sizeKB} KB)`);
|
|
358
|
+
console.log(` → ${cwdZip}`);
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error(`${c.red}Build failed:${c.reset}`, err.message);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Main ─────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
const args = process.argv.slice(2);
|
|
369
|
+
const command = args[0];
|
|
370
|
+
|
|
371
|
+
if (!command || command === '-h' || command === '--help') {
|
|
372
|
+
console.log(`
|
|
373
|
+
${c.bold}@openlearn/plugin-sdk${c.reset} — Plugin scaffolding & build tool
|
|
374
|
+
|
|
375
|
+
${c.bold}Usage:${c.reset}
|
|
376
|
+
npx @openlearn/plugin-sdk ${c.cyan}<command>${c.reset} [options]
|
|
377
|
+
|
|
378
|
+
${c.bold}Commands:${c.reset}
|
|
379
|
+
${c.cyan}init${c.reset} Scaffold a new OpenLearn plugin project
|
|
380
|
+
${c.cyan}build${c.reset} Build plugin into a distributable ZIP file
|
|
381
|
+
|
|
382
|
+
${c.bold}Init options:${c.reset}
|
|
383
|
+
--name <name> Plugin package name (kebab-case)
|
|
384
|
+
--description <desc> Short description
|
|
385
|
+
--author <author> Plugin author
|
|
386
|
+
--template <tpl> Template: server-only | full-stack | frontend-only
|
|
387
|
+
|
|
388
|
+
${c.bold}Build options:${c.reset}
|
|
389
|
+
--watch, -w Watch for changes and rebuild
|
|
390
|
+
|
|
391
|
+
${c.bold}Examples:${c.reset}
|
|
392
|
+
npx @openlearn/plugin-sdk init
|
|
393
|
+
npx @openlearn/plugin-sdk init --name my-poll --template full-stack
|
|
394
|
+
npx @openlearn/plugin-sdk build
|
|
395
|
+
npx @openlearn/plugin-sdk build --watch
|
|
396
|
+
`);
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
switch (command) {
|
|
401
|
+
case 'init':
|
|
402
|
+
cmdInit(args.slice(1));
|
|
403
|
+
break;
|
|
404
|
+
case 'build':
|
|
405
|
+
cmdBuild(args.slice(1));
|
|
406
|
+
break;
|
|
407
|
+
default:
|
|
408
|
+
console.error(`${c.red}Unknown command:${c.reset} ${command}`);
|
|
409
|
+
console.error(`Available: init, build`);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlearn/plugin-sdk",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "TypeScript
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "TypeScript types, DI tokens, CLI scaffolding & build tool for OpenLearn plugin development",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"educational",
|
|
7
|
+
"lms",
|
|
8
|
+
"openlearn",
|
|
9
|
+
"plugin",
|
|
10
|
+
"sdk",
|
|
11
|
+
"types"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/aymwoo/OpenLearn-Next-V2",
|
|
17
|
+
"directory": "packages/plugin-sdk"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"openlearn-plugin-sdk": "cli.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"cli.mjs",
|
|
25
|
+
"scaffold/"
|
|
26
|
+
],
|
|
5
27
|
"type": "module",
|
|
6
28
|
"main": "./dist/index.js",
|
|
7
29
|
"types": "./dist/index.d.ts",
|
|
@@ -12,32 +34,19 @@
|
|
|
12
34
|
"default": "./dist/index.js"
|
|
13
35
|
}
|
|
14
36
|
},
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
19
41
|
"scripts": {
|
|
20
42
|
"build": "node build.mjs",
|
|
21
43
|
"prepublishOnly": "node build.mjs"
|
|
22
44
|
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"esbuild": "^0.25.0",
|
|
47
|
+
"jszip": "^3.10.1"
|
|
48
|
+
},
|
|
23
49
|
"peerDependencies": {
|
|
24
50
|
"zod": ">=4"
|
|
25
|
-
},
|
|
26
|
-
"keywords": [
|
|
27
|
-
"openlearn",
|
|
28
|
-
"plugin",
|
|
29
|
-
"sdk",
|
|
30
|
-
"types",
|
|
31
|
-
"educational",
|
|
32
|
-
"lms"
|
|
33
|
-
],
|
|
34
|
-
"license": "MIT",
|
|
35
|
-
"repository": {
|
|
36
|
-
"type": "git",
|
|
37
|
-
"url": "https://github.com/openlearn/openlearnv2",
|
|
38
|
-
"directory": "packages/plugin-sdk"
|
|
39
|
-
},
|
|
40
|
-
"publishConfig": {
|
|
41
|
-
"access": "public"
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openlearn-plugin-{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "{{description}}",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "openlearn-plugin-sdk build",
|
|
9
|
+
"dev": "openlearn-plugin-sdk build --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@openlearn/plugin-sdk": "^{{sdkVersion}}"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "~5.8.0",
|
|
16
|
+
"@types/react": "^19.0.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": ">=17",
|
|
20
|
+
"react-dom": ">=17"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{pluginName}} — Frontend component
|
|
3
|
+
*
|
|
4
|
+
* Renders inside the host application. Uses host shared dependencies.
|
|
5
|
+
*/
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
export default function {{componentName}}() {
|
|
9
|
+
const [visible, setVisible] = useState(false);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div style={{ padding: 16 }}>
|
|
13
|
+
<h2>{{pluginName}}</h2>
|
|
14
|
+
<p>{{description}}</p>
|
|
15
|
+
<button onClick={() => setVisible(v => !v)}>
|
|
16
|
+
{visible ? 'Hide' : 'Show'} Details
|
|
17
|
+
</button>
|
|
18
|
+
{visible && <p style={{ marginTop: 8 }}>Hello from {{pluginName}}!</p>}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{pluginName}} — Frontend-only plugin manifest.
|
|
3
|
+
* No server-side handlers; pure UI extension.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
manifest: {
|
|
8
|
+
id: '{{pluginId}}',
|
|
9
|
+
name: '{{pluginName}}',
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
description: '{{description}}',
|
|
12
|
+
author: '{{author}}',
|
|
13
|
+
requires: [],
|
|
14
|
+
capabilitiesProposed: [],
|
|
15
|
+
classroomTools: [{
|
|
16
|
+
id: '{{pluginId}}-tool',
|
|
17
|
+
name: '{{pluginName}}',
|
|
18
|
+
icon: 'Palette',
|
|
19
|
+
commandType: '{{pluginId}}.open',
|
|
20
|
+
payload: {},
|
|
21
|
+
}],
|
|
22
|
+
engines: { openlearn: '>=5.0.0' },
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async activate(ctx: any) {
|
|
26
|
+
ctx.log?.info?.('{{pluginName}} activated (frontend-only)');
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async deactivate() {
|
|
30
|
+
console.log('{{pluginName}} deactivated');
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"rootDir": "src"
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openlearn-plugin-{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "{{description}}",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "openlearn-plugin-sdk build",
|
|
9
|
+
"dev": "openlearn-plugin-sdk build --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@openlearn/plugin-sdk": "^{{sdkVersion}}"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "~5.8.0",
|
|
16
|
+
"@types/react": "^19.0.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": ">=17",
|
|
20
|
+
"react-dom": ">=17"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{pluginName}} — Frontend entry point
|
|
3
|
+
*
|
|
4
|
+
* This component is rendered inside the host application when the classroom tool
|
|
5
|
+
* is activated. Host shared dependencies (react, react-dom, recharts, lucide-react)
|
|
6
|
+
* are provided via window.HostSharedDeps — do not bundle them.
|
|
7
|
+
*/
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export default function {{componentName}}() {
|
|
11
|
+
const [count, setCount] = useState(0);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ padding: 16 }}>
|
|
15
|
+
<h2>{{pluginName}}</h2>
|
|
16
|
+
<p>{{description}}</p>
|
|
17
|
+
<p>Counter: {count}</p>
|
|
18
|
+
<button onClick={() => setCount(c => c + 1)}>+1</button>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { PluginContext } from '@openlearn/plugin-sdk';
|
|
2
|
+
import {
|
|
3
|
+
ICommandBusServiceToken,
|
|
4
|
+
IActionRegistryServiceToken,
|
|
5
|
+
IEventBusServiceToken,
|
|
6
|
+
IDatabaseToken,
|
|
7
|
+
} from '@openlearn/plugin-sdk';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
manifest: {
|
|
11
|
+
id: '{{pluginId}}',
|
|
12
|
+
name: '{{pluginName}}',
|
|
13
|
+
version: '0.1.0',
|
|
14
|
+
description: '{{description}}',
|
|
15
|
+
author: '{{author}}',
|
|
16
|
+
requires: [
|
|
17
|
+
'@openlearn/core:ICommandBusService@^1.0.0',
|
|
18
|
+
'@openlearn/core:IActionRegistryService@^1.0.0',
|
|
19
|
+
'@openlearn/core:IEventBusService@^1.0.0',
|
|
20
|
+
'@openlearn/core:IDatabase@^1.0.0',
|
|
21
|
+
],
|
|
22
|
+
capabilitiesProposed: ['lesson:read', 'lesson:write'],
|
|
23
|
+
classroomTools: [{
|
|
24
|
+
id: '{{pluginId}}-tool',
|
|
25
|
+
name: '{{pluginName}}',
|
|
26
|
+
icon: 'Puzzle',
|
|
27
|
+
commandType: '{{pluginId}}.open_tool',
|
|
28
|
+
}],
|
|
29
|
+
engines: { openlearn: '>=5.0.0' },
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async activate(ctx: PluginContext) {
|
|
33
|
+
const commandBus = ctx.services.commandBus;
|
|
34
|
+
const actionRegistry = ctx.services.actionRegistry;
|
|
35
|
+
const eventBus = ctx.services.eventBus;
|
|
36
|
+
const db = await ctx.resolve(IDatabaseToken);
|
|
37
|
+
|
|
38
|
+
// Create plugin table
|
|
39
|
+
await ctx.db.ensureTable('data', `
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
content TEXT NOT NULL,
|
|
42
|
+
created_at INTEGER NOT NULL
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
// Register AI tool
|
|
46
|
+
await actionRegistry.register({
|
|
47
|
+
id: '{{pluginId}}-process',
|
|
48
|
+
commandType: '{{pluginId}}.process',
|
|
49
|
+
description: 'Process data for {{pluginName}}',
|
|
50
|
+
capabilityRequired: 'lesson:write',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'OBJECT',
|
|
53
|
+
properties: {
|
|
54
|
+
input: { type: 'STRING', description: 'Input data to process' },
|
|
55
|
+
},
|
|
56
|
+
required: ['input'],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Command handler
|
|
61
|
+
await commandBus.registerHandler('{{pluginId}}.process', {
|
|
62
|
+
async execute(command) {
|
|
63
|
+
const payload = command.payload as any;
|
|
64
|
+
const { input } = payload;
|
|
65
|
+
|
|
66
|
+
const tableName = ctx.db.table('data');
|
|
67
|
+
const id = crypto.randomUUID();
|
|
68
|
+
db.prepare(`INSERT INTO ${tableName} (id, content, created_at) VALUES (?, ?, ?)`)
|
|
69
|
+
.run(id, input, Date.now());
|
|
70
|
+
|
|
71
|
+
await eventBus.publish({
|
|
72
|
+
id: crypto.randomUUID(),
|
|
73
|
+
type: '{{pluginId}}.processed',
|
|
74
|
+
source: 'plugin.{{pluginId}}',
|
|
75
|
+
payload: { id, content: input },
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
correlationId: command.id,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { id, message: `Processed: ${input}` };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Classroom tool handler (opens frontend UI)
|
|
85
|
+
await actionRegistry.register({
|
|
86
|
+
id: '{{pluginId}}-open-tool',
|
|
87
|
+
commandType: '{{pluginId}}.open_tool',
|
|
88
|
+
description: 'Open {{pluginName}} classroom tool UI',
|
|
89
|
+
capabilityRequired: 'lesson:read',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'OBJECT',
|
|
92
|
+
properties: {
|
|
93
|
+
lessonId: { type: 'STRING', description: 'Current lesson ID' },
|
|
94
|
+
},
|
|
95
|
+
required: ['lessonId'],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await commandBus.registerHandler('{{pluginId}}.open_tool', {
|
|
100
|
+
async execute(command) {
|
|
101
|
+
return { opened: true, pluginId: '{{pluginId}}' };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ctx.log.info('{{pluginName}} activated (full-stack)');
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async deactivate() {
|
|
109
|
+
console.log('{{pluginName}} deactivated');
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"rootDir": "src"
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openlearn-plugin-{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "{{description}}",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "openlearn-plugin-sdk build",
|
|
9
|
+
"dev": "openlearn-plugin-sdk build --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@openlearn/plugin-sdk": "^{{sdkVersion}}"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "~5.8.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PluginContext } from '@openlearn/plugin-sdk';
|
|
2
|
+
import {
|
|
3
|
+
ICommandBusServiceToken,
|
|
4
|
+
IActionRegistryServiceToken,
|
|
5
|
+
IEventBusServiceToken,
|
|
6
|
+
} from '@openlearn/plugin-sdk';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
manifest: {
|
|
10
|
+
id: '{{pluginId}}',
|
|
11
|
+
name: '{{pluginName}}',
|
|
12
|
+
version: '0.1.0',
|
|
13
|
+
description: '{{description}}',
|
|
14
|
+
author: '{{author}}',
|
|
15
|
+
requires: [
|
|
16
|
+
'@openlearn/core:ICommandBusService@^1.0.0',
|
|
17
|
+
'@openlearn/core:IActionRegistryService@^1.0.0',
|
|
18
|
+
'@openlearn/core:IEventBusService@^1.0.0',
|
|
19
|
+
'@openlearn/core:IDatabase@^1.0.0',
|
|
20
|
+
],
|
|
21
|
+
capabilitiesProposed: ['lesson:read'],
|
|
22
|
+
engines: { openlearn: '>=5.0.0' },
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async activate(ctx: PluginContext) {
|
|
26
|
+
const commandBus = ctx.services.commandBus;
|
|
27
|
+
const actionRegistry = ctx.services.actionRegistry;
|
|
28
|
+
const eventBus = ctx.services.eventBus;
|
|
29
|
+
|
|
30
|
+
// Register AI tool
|
|
31
|
+
await actionRegistry.register({
|
|
32
|
+
id: '{{pluginId}}-hello',
|
|
33
|
+
commandType: '{{pluginId}}.hello',
|
|
34
|
+
description: 'Say hello — a starter action for {{pluginName}}',
|
|
35
|
+
capabilityRequired: 'lesson:read',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'OBJECT',
|
|
38
|
+
properties: {
|
|
39
|
+
name: { type: 'STRING', description: 'Who to greet' },
|
|
40
|
+
},
|
|
41
|
+
required: ['name'],
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Handle command
|
|
46
|
+
await commandBus.registerHandler('{{pluginId}}.hello', {
|
|
47
|
+
async execute(command) {
|
|
48
|
+
const payload = command.payload as any;
|
|
49
|
+
const message = `Hello, ${payload.name}! — from {{pluginName}}`;
|
|
50
|
+
|
|
51
|
+
await eventBus.publish({
|
|
52
|
+
id: crypto.randomUUID(),
|
|
53
|
+
type: '{{pluginId}}.hello_executed',
|
|
54
|
+
source: 'plugin.{{pluginId}}',
|
|
55
|
+
payload: { message },
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
correlationId: command.id,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return { message };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
ctx.log.info('{{pluginName}} activated successfully');
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async deactivate() {
|
|
68
|
+
console.log('{{pluginName}} deactivated');
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"rootDir": "src"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|