mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)
The author-facing SDK, a standalone dependency-free package (not wired into the app workspaces, so it can't affect the app build). - definePlugin + the full plugin type surface (PluginContext, PluginRoute, PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime injects; PLUGIN_API_VERSION. - createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the SAME permission model + membership checks, so authors can unit-test that their plugin degrades gracefully — no running TREK needed. - validateManifest: the exact rules the registry CI runs, so a local pass predicts a CI pass. - CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe) and trek-plugin validate (manifest + README sanity). Consolidating the server loader to import this shared validator is a follow-up.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* create-trek-plugin <name> [--type integration|page|widget] (#plugins, M6).
|
||||
* Scaffolds a working plugin: manifest, an isolated server entry using
|
||||
* definePlugin, a README you must fill in, and (page/widget) a starter iframe.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function scaffold(name: string, type: string, targetDir: string): void {
|
||||
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
|
||||
if (!['integration', 'page', 'widget'].includes(type)) throw new Error(`invalid type "${type}"`);
|
||||
|
||||
const root = path.join(targetDir, name);
|
||||
if (fs.existsSync(root)) throw new Error(`${root} already exists`);
|
||||
fs.mkdirSync(path.join(root, 'server'), { recursive: true });
|
||||
|
||||
const manifest: Record<string, unknown> = {
|
||||
id: name,
|
||||
name: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
version: '1.0.0',
|
||||
apiVersion: 1,
|
||||
author: 'Your Name',
|
||||
description: 'Describe what your plugin does.',
|
||||
type,
|
||||
trek: '>=3.2.0 <4.0.0',
|
||||
nativeModules: false,
|
||||
permissions: ['db:own'],
|
||||
routes: [{ method: 'GET', path: '/hello', auth: true }],
|
||||
};
|
||||
if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } };
|
||||
if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } };
|
||||
|
||||
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS);
|
||||
fs.writeFileSync(path.join(root, 'README.md'), readme(name));
|
||||
if (type !== 'integration') {
|
||||
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_JS = `// Built plugin entry — runs in an isolated child process.
|
||||
const { definePlugin } = require('trek-plugin-sdk');
|
||||
|
||||
module.exports = definePlugin({
|
||||
async onLoad(ctx) {
|
||||
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)');
|
||||
ctx.log.info('plugin loaded');
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
method: 'GET', path: '/hello', auth: true,
|
||||
async handler(req, ctx) {
|
||||
return { status: 200, headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ hello: req.user && req.user.username }) };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
const CLIENT_HTML = `<!doctype html>
|
||||
<html><head><meta charset="utf-8" /><title>Plugin</title></head>
|
||||
<body>
|
||||
<h1>Hello from your plugin</h1>
|
||||
<script>
|
||||
// The frame is sandboxed (opaque origin) — talk to TREK only via postMessage.
|
||||
window.parent.postMessage({ type: 'trek:ready' }, '*');
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'trek:context') {
|
||||
document.body.dataset.theme = e.data.theme;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
function readme(name: string): string {
|
||||
return `# ${name}
|
||||
|
||||
> One sentence: what this plugin does.
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
Describe the feature this plugin adds to TREK.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Show it in context (at least one image).
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| \`db:own\` | store the plugin's own data |
|
||||
|
||||
## Setup
|
||||
|
||||
How to configure it.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
`;
|
||||
}
|
||||
|
||||
// CLI entry
|
||||
if (process.argv[1] && process.argv[1].endsWith('create.js')) {
|
||||
const args = process.argv.slice(2);
|
||||
const name = args.find((a: string) => !a.startsWith('-'));
|
||||
const typeIdx = args.indexOf('--type');
|
||||
const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration';
|
||||
if (!name) {
|
||||
console.error('usage: create-trek-plugin <name> [--type integration|page|widget]');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
scaffold(name, type, process.cwd());
|
||||
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin validate ${name}\`.`);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/** `trek-plugin <command>` dispatcher (#plugins, M6). Commands: validate. */
|
||||
import { validatePluginDir } from './validate.js';
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (cmd === 'validate') {
|
||||
const r = validatePluginDir(rest[0] || '.');
|
||||
for (const w of r.warnings) console.warn('warning: ' + w);
|
||||
if (r.ok) {
|
||||
console.log('✓ plugin is valid');
|
||||
} else {
|
||||
for (const e of r.errors) console.error('error: ' + e);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error('usage: trek-plugin validate [dir]');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* trek-plugin validate [dir] (#plugins, M6). Runs the SAME manifest checks the
|
||||
* registry CI runs, plus a light README sanity check — so a local pass predicts
|
||||
* a CI pass. Returns a structured result; the CLI prints + exits non-zero on
|
||||
* failure.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { validateManifest } from '../manifest.js';
|
||||
|
||||
export interface ValidateReport {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function validatePluginDir(dir: string): ValidateReport {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const manifestPath = path.join(dir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) return { ok: false, errors: ['no trek-plugin.json in ' + dir], warnings };
|
||||
|
||||
let manifestId = '';
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const res = validateManifest(raw);
|
||||
errors.push(...res.errors);
|
||||
manifestId = res.manifest?.id ?? String(raw.id ?? '');
|
||||
// dir name should match the id
|
||||
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
|
||||
warnings.push(`directory name should equal the plugin id "${manifestId}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
// README sanity
|
||||
const readmePath = path.join(dir, 'README.md');
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
errors.push('README.md is missing');
|
||||
} else {
|
||||
const md = fs.readFileSync(readmePath, 'utf8');
|
||||
if (!/!\[[^\]]*\]\([^)]+\)/.test(md)) warnings.push('README has no screenshot (registry requires at least one)');
|
||||
if (/\{\{[^}]*\}\}|REPLACE_ME|Describe (what|the)|One sentence:/i.test(md)) {
|
||||
warnings.push('README still contains template placeholders — fill it in before publishing');
|
||||
}
|
||||
}
|
||||
|
||||
// Server entry present
|
||||
if (!fs.existsSync(path.join(dir, 'server', 'index.js'))) errors.push('server/index.js is missing (build your plugin first)');
|
||||
|
||||
return { ok: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('validate.js')) {
|
||||
const dir = process.argv[2] || '.';
|
||||
const r = validatePluginDir(dir);
|
||||
for (const w of r.warnings) console.warn('warning: ' + w);
|
||||
if (r.ok) {
|
||||
console.log('✓ plugin is valid');
|
||||
} else {
|
||||
for (const e of r.errors) console.error('error: ' + e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user