@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 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.1.0",
4
- "description": "TypeScript type definitions and DI tokens for OpenLearn plugin development",
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
- "files": [
16
- "dist/",
17
- "README.md"
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
+ }