diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index f51b781e3..0bd7684f1 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -5,12 +5,26 @@ The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins. ## Scaffold a plugin ```bash -npx trek-plugin-sdk create my-plugin --type integration|page|widget +npx trek-plugin-sdk create # interactive wizard (id, type, permissions) +npx trek-plugin-sdk create my-plugin --type widget # or non-interactive cd my-plugin -# build server/index.js, fill in the README -npx trek-plugin-sdk validate . ``` +## Develop with a live reload loop + +`dev` runs your plugin locally — no full TREK needed. It injects a `ctx` that +enforces exactly the permissions your manifest grants (an ungranted call throws +`PERMISSION_DENIED`, so you catch missing grants), backs `db:own` with a real +SQLite file, serves your routes and your page/widget UI, and reloads on save. + +```bash +npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI +``` + +Hit a route as an unauthenticated request with `?_anon=1`. Drop a +`dev-fixtures.json` (trips, users, config) next to your manifest to feed +`ctx.trips` / `ctx.users`. + ## Write a plugin ```js @@ -48,24 +62,44 @@ const { ctx, broadcasts } = createMockHost({ ## Publish The SDK does the fiddly parts (zipping, hashing, sizing, writing the registry -entry) so you don't compute anything by hand: +entry, opening the PR) so you don't compute anything by hand. The short path: ```bash -npx trek-plugin-sdk validate # manifest + layout OK? -npx trek-plugin-sdk pack # -> plugin.zip, prints sha256 + size -gh release create v1.0.0 plugin.zip # attach the artifact to your tag -npx trek-plugin-sdk entry --repo you/repo --tag v1.0.0 - # -> the ready-to-PR registry entry JSON +git tag v1.0.0 && git push --tags +npx trek-plugin-sdk release --repo you/repo --tag v1.0.0 # pack -> GitHub release -> entry +npx trek-plugin-sdk submit --repo you/repo --tag v1.0.0 # opens the registry PR for you ``` -Or in one step: `npx trek-plugin-sdk release --repo you/repo --tag v1.0.0` packs, -creates the GitHub release, and prints the entry. Then paste the entry into a PR -against [TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) as -`registry/plugins/.json`. +`submit` forks [TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins), +branches off the current main, writes (or merges into) `registry/plugins/.json`, +and opens the PR — the last manual step is gone. Prefer to paste it yourself? +`entry` just prints the JSON. -**Updating** an already-listed plugin: bump `version`, tag, and pass -`--merge registry/plugins/.json` to `entry` — it prepends the new version, -keeping the array newest-first. +**Check before you submit** — run the exact registry CI checks locally so you +don't round-trip through review: + +```bash +npx trek-plugin-sdk preflight --repo you/repo --tag v1.0.0 +# verifies the tag→commit, manifest parity, artifact sha256/size, native scan, +# and the README quality gate — over the network, against your pushed release. +``` + +**Updating** a listed plugin: bump `version`, tag, and re-run `submit` — it +detects the existing entry and prepends the new version, newest-first. + +### Sign your releases (optional, recommended) + +Give your plugin a stable identity. TREK pins your key on first install +(trust-on-first-use); afterwards an unsigned or wrong-key update is refused. + +```bash +npx trek-plugin-sdk keygen # once — writes ~/.trek-plugin/signing.key +npx trek-plugin-sdk release --repo you/repo --tag v1.1.0 --sign +npx trek-plugin-sdk submit --repo you/repo --tag v1.1.0 --sign +``` + +Signing is dependency-free Ed25519 over the artifact bytes. **Back up the key** — +losing it means you can't ship signed updates. ## Exports @@ -74,12 +108,20 @@ keeping the array newest-first. - `validateManifest(json)` — the manifest rules the server loader uses. - `createMockHost(opts)` (from `trek-plugin-sdk/testing`). -## CLIs +## Commands -- `create-trek-plugin --type …` — scaffold a working plugin. -- `trek-plugin validate [dir]` — check the manifest + layout locally (the manifest rules the registry CI also runs; CI additionally verifies the release, artifact hash and README over the network). -- `trek-plugin pack [dir] [--out plugin.zip] [--json]` — build the artifact, print `sha256` + `size`. -- `trek-plugin entry --repo o/n --tag vX [--zip z] [--merge entry.json] [--out f]` — emit the registry entry. -- `trek-plugin release [dir] --repo o/n --tag vX` — pack → GitHub release → entry, in one go. +Run any of these with `npx trek-plugin-sdk ` (or the short `trek-plugin` +bin if you install the package): + +- `create [name] [--type t] [--interactive]` — scaffold a plugin; a wizard if you omit the name. +- `dev [dir] [--port 4317]` — run locally with a real request loop, SQLite `db:own`, and hot reload. +- `validate [dir]` — manifest + layout checks (a subset of registry CI, offline). +- `pack [dir] [--out plugin.zip] [--json]` — build the artifact, print `sha256` + `size`. +- `keygen [--key file]` — create an Ed25519 signing key. +- `sign [zip] [--key file]` — print a signature + public key for an artifact. +- `entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f]` — emit the registry entry JSON. +- `preflight --repo o/n --tag vX` (or `--entry f`) — run the registry CI checks locally, over the network. +- `submit --repo o/n --tag vX [--sign [key]] [--draft]` — open the registry PR for you. +- `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, in one go. The SDK tooling in this repo is MIT. Your plugin is your own code under your own license. diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index 998ae6711..5af7370c2 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "trek-plugin-sdk", - "version": "1.0.0", + "version": "1.1.0", "description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.", "type": "module", "license": "MIT", diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index f6fcd9762..4ffb50960 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -6,8 +6,15 @@ */ import fs from 'node:fs'; import path from 'node:path'; +import readline from 'node:readline'; -export function scaffold(name: string, type: string, targetDir: string): void { +export interface ScaffoldOptions { + author?: string; + description?: string; + permissions?: string[]; +} + +export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): 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}"`); @@ -15,17 +22,18 @@ export function scaffold(name: string, type: string, targetDir: string): void { if (fs.existsSync(root)) throw new Error(`${root} already exists`); fs.mkdirSync(path.join(root, 'server'), { recursive: true }); + const perms = opts.permissions?.length ? opts.permissions : ['db:own']; const manifest: Record = { 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.', + author: opts.author || 'Your Name', + description: opts.description || 'Describe what your plugin does.', type, trek: '>=3.2.0 <4.0.0', nativeModules: false, - permissions: ['db:own'], + permissions: perms, routes: [{ method: 'GET', path: '/hello', auth: true }], }; if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } }; @@ -110,6 +118,38 @@ one. Replace this line with your license (for example, MIT). `; } +const KNOWN_PERMISSIONS = [ + 'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', + 'hook:photo-provider', 'hook:calendar-source', 'http:outbound', +]; + +/** Interactive scaffold: prompt for the details, then create the plugin. Returns the chosen name. */ +export async function interactiveScaffold(targetDir: string, presetName?: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string, def?: string) => + new Promise((resolve) => rl.question(def ? `${q} (${def}) ` : `${q} `, (a) => resolve(a.trim() || def || ''))); + try { + let name = presetName || ''; + while (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) { + name = await ask('Plugin id (lowercase-slug):'); + if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) console.log(' → must be a lowercase slug, 3–40 chars (e.g. flight-tracker)'); + } + let type = ''; + while (!['integration', 'page', 'widget'].includes(type)) { + type = (await ask('Type — integration | page | widget:', 'integration')).toLowerCase(); + } + const author = await ask('Author:', 'Your Name'); + const description = await ask('One-line description:', 'Describe what your plugin does.'); + console.log(`\n Permissions (space/comma separated). Available:\n ${KNOWN_PERMISSIONS.join(', ')}`); + const permsRaw = await ask('Permissions:', 'db:own'); + const permissions = permsRaw.split(/[\s,]+/).filter(Boolean).filter((p) => KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:')); + scaffold(name, type, targetDir, { author, description, permissions }); + return name; + } finally { + rl.close(); + } +} + // CLI entry if (process.argv[1] && process.argv[1].endsWith('create.js')) { const args = process.argv.slice(2); diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts new file mode 100644 index 000000000..3c30a5c66 --- /dev/null +++ b/plugin-sdk/src/cli/dev.ts @@ -0,0 +1,246 @@ +/** + * trek-plugin dev — run your plugin locally with a real request loop and hot + * reload, without a full TREK. It loads server/index.js through the same + * definePlugin contract, injects a ctx that enforces your manifest's granted + * permissions (an ungranted call still throws PERMISSION_DENIED, so you catch + * missing grants), backs db:own with a real SQLite file when the runtime has + * node:sqlite, serves your routes over HTTP and your page/widget UI, and reloads + * on every save. + * + * Dependency-free: node:http + node:sqlite (when present) + built-ins. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import { createRequire } from 'node:module'; + +interface Fixtures { + config?: Record; + trips?: Record; + users?: Record; +} +interface PluginRouteLike { method: string; path: string; auth?: boolean; handler: (req: unknown, ctx: unknown) => Promise<{ status: number; headers?: Record; body?: unknown }>; } +interface PluginLike { onLoad?: (ctx: unknown) => unknown; routes?: PluginRouteLike[]; } + +class PermissionDenied extends Error {} + +/** A dev db backed by node:sqlite if available, else an in-memory recorder that returns []. */ +function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } { + try { + // node:sqlite is available on Node 22.5+ (experimental). Fall back gracefully if not. + const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb }; + fs.mkdirSync(path.dirname(dbFile), { recursive: true }); + const sq = new DatabaseSync(dbFile); + const applied = new Set(); + return { + note: `db:own → real SQLite at ${dbFile}`, + close: () => { try { sq.close(); } catch { /* ignore */ } }, + db: { + async query(sql: string, ...args: unknown[]) { return sq.prepare(sql).all(...args) as unknown[]; }, + async exec(sql: string, ...args: unknown[]) { + if (args.length) { const r = sq.prepare(sql).run(...args); return { changes: Number(r.changes ?? 0) }; } + sq.exec(sql); return { changes: 0 }; + }, + async migrate(id: string, sql: string) { if (applied.has(id)) return { applied: false }; sq.exec(sql); applied.add(id); return { applied: true }; }, + }, + }; + } catch { + return { + note: 'db:own → in-memory stub (upgrade to Node 22.5+ for a real SQLite dev db)', + close: () => {}, + db: { + async query() { return []; }, + async exec() { return { changes: 0 }; }, + async migrate() { return { applied: true }; }, + }, + }; + } +} + +interface SqliteDb { prepare(sql: string): { all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes?: number | bigint } }; exec(sql: string): void; close(): void; } +interface PluginContextDb { query(sql: string, ...a: unknown[]): Promise; exec(sql: string, ...a: unknown[]): Promise<{ changes: number }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>; } + +function createDevContext(id: string, grants: Set, fx: Fixtures, db: PluginContextDb, broadcasts: unknown[]) { + const need = (perm: string, method: string) => { if (!grants.has(perm)) throw new PermissionDenied(`PERMISSION_DENIED: ${method} requires ${perm}`); }; + const member = (tripId: number, asUser: number) => { + const t = fx.trips?.[tripId]; + if (!t || !t.members.includes(asUser)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`); + return t; + }; + return { + id, config: Object.freeze({ ...(fx.config ?? {}) }), + db: { + query: (sql: string, ...a: unknown[]) => { need('db:own', 'db.query'); return db.query(sql, ...a); }, + exec: (sql: string, ...a: unknown[]) => { need('db:own', 'db.exec'); return db.exec(sql, ...a); }, + migrate: (mid: string, sql: string) => { need('db:own', 'db.migrate'); return db.migrate(mid, sql); }, + }, + trips: { + getById: async (t: number, u: number) => { need('db:read:trips', 'trips.getById'); return member(t, u).data ?? null; }, + getPlaces: async (t: number, u: number) => { need('db:read:trips', 'trips.getPlaces'); return member(t, u).places ?? []; }, + getReservations: async (t: number, u: number) => { need('db:read:trips', 'trips.getReservations'); return member(t, u).reservations ?? []; }, + }, + users: { getById: async (uid: number) => { need('db:read:users', 'users.getById'); return fx.users?.[uid] ?? null; } }, + ws: { + broadcastToTrip: async (t: number, event: string, data: unknown) => { need('ws:broadcast:trip', 'ws.broadcastToTrip'); broadcasts.push({ kind: 'trip', target: t, event, data }); }, + broadcastToUser: async (u: number, event: string, data: unknown) => { need('ws:broadcast:user', 'ws.broadcastToUser'); broadcasts.push({ kind: 'user', target: u, event, data }); }, + }, + log: { + info: (m: string) => console.log(` [plugin] ${m}`), + warn: (m: string) => console.warn(` [plugin] ${m}`), + error: (m: string) => console.error(` [plugin] ${m}`), + }, + }; +} + +function loadFixtures(dir: string): Fixtures { + const p = path.join(dir, 'dev-fixtures.json'); + if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } } + return {}; +} + +/** Watch a directory tree with per-dir fs.watch (works on every platform, unlike recursive). */ +function watchTree(root: string, onChange: () => void): void { + const skip = new Set(['node_modules', '.git', '.trek-dev']); + const watch = (d: string) => { + try { + fs.watch(d, (_e, f) => { if (!f || !skip.has(f)) onChange(); }); + for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory() && !skip.has(e.name)) watch(path.join(d, e.name)); + } catch { /* dir vanished */ } + }; + if (fs.existsSync(root)) watch(root); +} + +export async function runDev(dir: string, opts: { port?: number } = {}): Promise { + const abs = path.resolve(dir); + const manifestPath = path.join(abs, 'trek-plugin.json'); + if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json in ${abs}`); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + const id = String(manifest.id); + const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []); + const fx = loadFixtures(abs); + const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite')); + const broadcasts: unknown[] = []; + const req = createRequire(path.join(abs, 'server', 'index.js')); + + let plugin: PluginLike = {}; + let version = 0; + const ctx = createDevContext(id, grants, fx, db, broadcasts); + + const load = async () => { + const entry = path.join(abs, 'server', 'index.js'); + if (!fs.existsSync(entry)) { console.error(' server/index.js is missing — build your plugin'); plugin = {}; return; } + for (const k of Object.keys(req.cache)) if (k.startsWith(abs) && !k.includes('node_modules')) delete req.cache[k]; + try { + const mod = req(entry) as PluginLike & { default?: PluginLike }; + plugin = mod.default ?? mod; + await plugin.onLoad?.(ctx); + version++; + console.log(` ↻ loaded ${plugin.routes?.length ?? 0} route(s)`); + } catch (e) { console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e); } + }; + await load(); + + let timer: NodeJS.Timeout | null = null; + const reload = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { console.log(' change detected'); void load(); }, 120); }; + watchTree(path.join(abs, 'server'), reload); + watchTree(path.join(abs, 'client'), reload); + + const server = http.createServer((request, res) => { void handle(request, res); }); + + async function handle(request: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(request.url || '/', 'http://localhost'); + const send = (status: number, body: string, type = 'text/plain; charset=utf-8', headers: Record = {}) => { + res.writeHead(status, { 'content-type': type, ...headers }); res.end(body); + }; + + if (url.pathname === '/__dev/version') return send(200, String(version)); + if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8'); + + // Static plugin UI at /ui (page/widget client bundle) + if (url.pathname === '/ui' || url.pathname.startsWith('/ui/')) { + const relFile = url.pathname === '/ui' ? 'index.html' : url.pathname.slice('/ui/'.length); + const file = path.join(abs, 'client', relFile); + if (!file.startsWith(path.join(abs, 'client'))) return send(403, 'forbidden'); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle'); + let data: Buffer | string = fs.readFileSync(file); + const type = contentType(file); + if (type.startsWith('text/html')) data = String(data).replace('', `${LIVE_RELOAD}`); + return send(200, data.toString(), type); + } + + // Plugin API routes mounted under /api/ + if (url.pathname.startsWith('/api/')) { + const pluginPath = url.pathname.slice('/api'.length); // "/api/hello" -> "/hello" + const route = (plugin.routes ?? []).find((r) => r.method.toUpperCase() === request.method && r.path === pluginPath); + if (!route) return send(404, `no ${request.method} route ${pluginPath}`); + const anon = url.searchParams.get('_anon') === '1'; + if (route.auth !== false && anon) return send(401, 'this route requires auth (drop ?_anon=1 to send the dev user)'); + const user = anon ? null : { id: 1, username: 'dev', isAdmin: true }; + const body = await readBody(request); + const query: Record = {}; url.searchParams.forEach((v, k) => { if (k !== '_anon') query[k] = v; }); + try { + const r = await route.handler({ method: route.method, path: route.path, query, body, user }, ctx); + const out = typeof r.body === 'string' || Buffer.isBuffer(r.body) ? String(r.body) : JSON.stringify(r.body ?? null); + return send(r.status ?? 200, out, r.headers?.['content-type'] || 'application/json; charset=utf-8', r.headers ?? {}); + } catch (e) { + const denied = e instanceof PermissionDenied; + return send(denied ? 403 : 500, (e instanceof Error ? e.message : String(e))); + } + } + return send(404, 'not found'); + } + + const port = opts.port ?? 4317; + await new Promise((resolve) => server.listen(port, resolve)); + const routes = plugin.routes ?? []; + console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`); + console.log(` ${dbNote}`); + console.log(` granted: ${[...grants].join(', ') || '(none)'}`); + console.log(`\n ▸ http://localhost:${port}/ dashboard`); + if (String(manifest.type) !== 'integration') console.log(` ▸ http://localhost:${port}/ui your plugin UI`); + for (const r of routes) console.log(` ▸ ${r.method.padEnd(4)} http://localhost:${port}/api${r.path}${r.auth === false ? ' (public)' : ''}`); + console.log('\n editing server/ or client/ hot-reloads. Ctrl+C to stop.\n'); + + const shutdown = () => { closeDb(); server.close(); process.exit(0); }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve(undefined); + if ((req.headers['content-type'] || '').includes('application/json')) { try { return resolve(JSON.parse(raw)); } catch { /* raw */ } } + resolve(raw); + }); + req.on('error', () => resolve(undefined)); + }); +} + +function contentType(file: string): string { + const ext = path.extname(file).toLowerCase(); + return ({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' } as Record)[ext] || 'application/octet-stream'; +} + +const LIVE_RELOAD = ``; + +function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number): string { + const rows = routes.length + ? routes.map((r) => `${r.method}/api${r.path}${r.auth === false ? 'public' : 'auth'}`).join('') + : 'no routes declared'; + const ui = type !== 'integration' ? `

▸ open your plugin UI (/ui)

` : ''; + return `${id} · trek-plugin dev + +

${id} · ${type}

+

${dbNote} · ${bcasts} ws broadcast(s) captured

+${ui} +${rows}
MethodURLAuth
+

Add ?_anon=1 to a route to hit it as an unauthenticated request. Edit server/ or client/ and this reloads.

+${LIVE_RELOAD}`; +} diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index 0d742872d..fa39c8d3c 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -13,15 +13,16 @@ import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; +import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js'; interface Version { version: string; gitTag: string; commitSha: string; downloadUrl: string; sha256: string; minTrekVersion: string; size: number; apiVersion: number; - nativeModules: false; publishedAt: string; + nativeModules: false; publishedAt: string; signature?: string; } interface Entry { id: string; name: string; author: string; description: string; repo: string; - homepage?: string; tags?: string[]; type: string; versions: Version[]; + homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[]; } /** Lower bound of a `trek` range like ">=3.2.0 <4.0.0" -> "3.2.0" (matches the server). */ @@ -43,6 +44,8 @@ function resolveCommit(dir: string, tag: string, override?: string): string { export function buildEntry(opts: { dir: string; repo: string; tag: string; zipPath: string; commit?: string; asset?: string; mergePath?: string; now: string; + /** Optional Ed25519 private-key file — signs the artifact and pins the author key. */ + signKeyPath?: string; }): Entry { const manifest = JSON.parse(fs.readFileSync(path.join(opts.dir, 'trek-plugin.json'), 'utf8')) as Record; if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`); @@ -65,10 +68,24 @@ export function buildEntry(opts: { publishedAt: opts.now, }; + // Optional author signature over the exact artifact bytes. + let authorPublicKey: string | undefined; + if (opts.signKeyPath) { + const key = loadPrivateKey(opts.signKeyPath); + version.signature = signArtifact(buf, key); + authorPublicKey = publicKeyBase64(key); + } + if (opts.mergePath) { const existing = JSON.parse(fs.readFileSync(opts.mergePath, 'utf8')) as Entry; + if (authorPublicKey && existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey) { + throw new Error('this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.'); + } + if (existing.authorPublicKey && !authorPublicKey) { + throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.'); + } const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)]; - return { ...existing, versions }; + return { ...existing, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions }; } const entry: Entry = { @@ -82,5 +99,6 @@ export function buildEntry(opts: { }; if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage; if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8); + if (authorPublicKey) entry.authorPublicKey = authorPublicKey; return entry; } diff --git a/plugin-sdk/src/cli/preflight.ts b/plugin-sdk/src/cli/preflight.ts new file mode 100644 index 000000000..aafc3b61f --- /dev/null +++ b/plugin-sdk/src/cli/preflight.ts @@ -0,0 +1,160 @@ +/** + * trek-plugin preflight — run the registry CI checks locally, over the network, + * BEFORE you open a PR. It mirrors TREK-Plugins' validate-entry.mjs (tag→commit, + * manifest parity, artifact sha256/size, native-binary scan) and check-readme.mjs + * (required sections, real prose, a resolving screenshot, permission parity), so + * you catch what CI would reject without a round-trip through review. + * + * Dependency-free: global fetch + Node built-ins + our own zip reader. + */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { listZipNames } from '../zip.js'; + +const REQUIRED_HEADINGS = ['what it does', 'screenshots', 'permissions', 'setup']; +const PLACEHOLDER_RE = [/\{\{[^}]*\}\}/, /\bREPLACE_ME\b/i, /\bDescribe (what|the)\b/i, /\byour-name\/trek-plugin/i]; +const MIN_PROSE = 400; +const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; + +export interface EntryVersion { + version: string; gitTag: string; commitSha: string; downloadUrl: string; + sha256: string; size: number; apiVersion: number; nativeModules?: boolean; signature?: string; +} +export interface Entry { + id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[]; +} +export interface PreflightReport { ok: boolean; failures: string[]; passed: string[]; } + +function ghToken(): string | undefined { + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; } +} + +function raw(repo: string, sha: string, p: string): string { + return `https://raw.githubusercontent.com/${repo}/${sha}/${p.replace(/^\.?\//, '')}`; +} + +export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Promise { + const failures: string[] = []; + const passed: string[] = []; + const fail = (m: string) => failures.push(m); + const ok = (m: string) => passed.push(m); + + const token = ghToken(); + const ghHeaders: Record = { 'User-Agent': 'trek-plugin-preflight', Accept: 'application/vnd.github+json' }; + if (token) ghHeaders.Authorization = `Bearer ${token}`; + + // Structural checks that mirror the registry schema (fast, offline). + if (!/^[a-z][a-z0-9-]{2,39}$/.test(entry.id)) fail(`id "${entry.id}" is not a valid slug (^[a-z][a-z0-9-]{2,39}$)`); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) fail(`repo "${entry.repo}" is not "owner/name"`); + if (!['integration', 'page', 'widget'].includes(entry.type)) fail(`type "${entry.type}" is not integration|page|widget`); + if (entry.authorPublicKey && !entry.versions[0]?.signature) fail('entry has authorPublicKey but the newest version has no signature (sign the version too)'); + + const versions = opts.all ? entry.versions : entry.versions.slice(0, 1); + for (const v of versions) { + const tag = `${v.version}`; + // 1. tag exists and resolves to commitSha + try { + const r = await fetch(`https://api.github.com/repos/${entry.repo}/git/refs/tags/${encodeURIComponent(v.gitTag)}`, { headers: ghHeaders }); + if (!r.ok) fail(`${tag}: tag ${v.gitTag} not found on GitHub (${r.status}) — push the tag and create the release`); + else { + const ref = (await r.json()) as { object?: { sha?: string; type?: string } }; + let sha = ref.object?.sha; + if (ref.object?.type === 'tag' && sha) { + const tr = await fetch(`https://api.github.com/repos/${entry.repo}/git/tags/${sha}`, { headers: ghHeaders }); + if (tr.ok) sha = ((await tr.json()) as { object?: { sha?: string } }).object?.sha; + } + if (sha && sha !== v.commitSha) fail(`${tag}: tag ${v.gitTag} points at ${sha.slice(0, 8)}, entry pins ${v.commitSha.slice(0, 8)}`); + else if (sha) ok(`${tag}: tag resolves to the pinned commit`); + } + } catch (e) { fail(`${tag}: tag check failed: ${(e as Error).message}`); } + + // 2. manifest parity at the pinned commit (+ collect permissions for the README gate) + let manifestPerms: string[] = []; + try { + const mr = await fetch(raw(entry.repo, v.commitSha, 'trek-plugin.json'), { headers: { 'User-Agent': 'trek-plugin-preflight' } }); + if (!mr.ok) fail(`${tag}: trek-plugin.json not found at ${v.commitSha.slice(0, 8)} (${mr.status})`); + else { + const m = JSON.parse(await mr.text()) as Record; + if (m.id !== entry.id) fail(`${tag}: manifest id "${m.id}" != entry id "${entry.id}"`); + if (m.version !== v.version) fail(`${tag}: manifest version "${m.version}" != entry "${v.version}"`); + if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`); + if (m.apiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${m.apiVersion} != entry ${v.apiVersion}`); + if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`); + manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : []; + if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { + const egress = Array.isArray(m.egress) ? (m.egress as string[]) : []; + if (!egress.length) fail(`${tag}: http:outbound declared but egress[] is empty`); + if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`); + } + if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`); + } + } catch (e) { fail(`${tag}: manifest parity failed: ${(e as Error).message}`); } + + // 3. artifact download → sha256 + size + native-binary scan + try { + const dr = await fetch(v.downloadUrl, { redirect: 'follow', headers: { 'User-Agent': 'trek-plugin-preflight' } }); + if (!dr.ok) fail(`${tag}: artifact download failed (${dr.status}) ${v.downloadUrl}`); + else { + const bytes = Buffer.from(await dr.arrayBuffer()); + if (bytes.length > v.size + 4096) fail(`${tag}: artifact (${bytes.length}B) larger than declared size (${v.size}B)`); + const sha = createHash('sha256').update(bytes).digest('hex'); + if (sha !== v.sha256) fail(`${tag}: sha256 mismatch — release has ${sha.slice(0, 12)}…, entry pins ${v.sha256.slice(0, 12)}… (re-pack + re-upload?)`); + else ok(`${tag}: artifact sha256 matches the release`); + let names: string[] = []; + try { names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; } catch { /* not a zip */ } + if (names.some((n) => NATIVE_RE.test(n))) fail(`${tag}: artifact contains native binaries (.node / binding.gyp / prebuilds)`); + } + } catch (e) { fail(`${tag}: artifact check failed: ${(e as Error).message}`); } + + // 4. README quality gate at the pinned commit (mirrors check-readme.mjs) + await readmeGate(entry.repo, v.commitSha, manifestPerms, tag, fail, ok); + } + + return { ok: failures.length === 0, failures, passed }; +} + +async function readmeGate(repo: string, sha: string, perms: string[], tag: string, fail: (m: string) => void, ok: (m: string) => void): Promise { + let md: string; + try { + const r = await fetch(raw(repo, sha, 'README.md'), { headers: { 'User-Agent': 'trek-plugin-preflight' } }); + if (!r.ok) { fail(`${tag}: README.md missing at the repo root (${r.status})`); return; } + md = await r.text(); + } catch (e) { fail(`${tag}: README fetch failed: ${(e as Error).message}`); return; } + + const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1].toLowerCase()); + for (const h of REQUIRED_HEADINGS) if (!headings.some((got) => got.includes(h))) fail(`${tag}: README missing required section "## ${h}"`); + for (const re of PLACEHOLDER_RE) { const hit = md.match(re); if (hit) fail(`${tag}: README still has a template placeholder "${hit[0]}"`); } + + const prose = md + .replace(//g, '').replace(/```[\s\S]*?```/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[[^\]]*\]\([^)]*\)/g, '') + .replace(/^#{1,6}\s+.*$/gm, '').replace(/^\s*\|.*$/gm, '') + .replace(/[#>*_`|-]/g, '').replace(/\s+/g, ' ').trim(); + if (prose.length < MIN_PROSE) fail(`${tag}: README has too little prose (${prose.length} chars, need ≥ ${MIN_PROSE})`); + + const imgs = [ + ...[...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]), + ...[...md.matchAll(/]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]), + ].filter((u) => !u.startsWith('data:')); + if (!imgs.length) fail(`${tag}: README has no screenshot (at least one image required)`); + else { + let anyOk = false; + for (const src of [...new Set(imgs)]) { + const url = /^https?:\/\//.test(src) + ? (src.includes('github.com') && src.includes('/blob/') ? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') : src) + : raw(repo, sha, src); + try { + const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } }); + if (r.ok && (r.headers.get('content-type') || '').startsWith('image/')) { anyOk = true; break; } + } catch { /* try next */ } + } + if (!anyOk) fail(`${tag}: no README screenshot resolved to a real image at the pinned commit`); + else ok(`${tag}: README screenshot resolves`); + } + + const lower = md.toLowerCase(); + const undocumented = perms.filter((p) => !lower.includes(p.toLowerCase())); + if (undocumented.length) fail(`${tag}: permissions not explained in the README: ${undocumented.join(', ')}`); + else if (perms.length) ok(`${tag}: all permissions documented`); +} diff --git a/plugin-sdk/src/cli/sign.ts b/plugin-sdk/src/cli/sign.ts new file mode 100644 index 000000000..412444808 --- /dev/null +++ b/plugin-sdk/src/cli/sign.ts @@ -0,0 +1,71 @@ +/** + * Author signing for plugins (#plugins). Optional trust-on-first-use identity: + * you sign your plugin.zip with an Ed25519 key, publish the public key once in + * the registry entry (`authorPublicKey`) and a per-version `signature`, and TREK + * pins your key on first install — a later unsigned or wrong-key update is then + * refused. + * + * Fully dependency-free: Node's `crypto` does raw Ed25519. The output matches + * exactly what the server verifies — a bare 64-byte Ed25519 signature over the + * raw artifact bytes (base64), and a bare 32-byte public key (base64). See the + * server's install/verify-signature.ts. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +/** Default key location — outside the project so it's never committed by accident. */ +export function defaultKeyPath(): string { + return path.join(os.homedir(), '.trek-plugin', 'signing.key'); +} + +/** Generate an Ed25519 signing key, write the PRIVATE key (PEM) to keyPath, return its base64 public key. */ +export function generateKeypair(keyPath: string): { publicKey: string; keyPath: string } { + if (fs.existsSync(keyPath)) { + throw new Error(`a key already exists at ${keyPath} — refusing to overwrite it (delete it yourself if you really mean to)`); + } + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string; + fs.mkdirSync(path.dirname(keyPath), { recursive: true }); + fs.writeFileSync(keyPath, pem, { mode: 0o600 }); + try { fs.chmodSync(keyPath, 0o600); } catch { /* windows: best effort */ } + return { publicKey: publicKeyBase64(privateKey), keyPath }; +} + +/** Load an Ed25519 private key from a PEM file. */ +export function loadPrivateKey(keyPath: string): crypto.KeyObject { + if (!fs.existsSync(keyPath)) { + throw new Error(`no signing key at ${keyPath} — run \`trek-plugin keygen\` first (or pass --key )`); + } + const key = crypto.createPrivateKey(fs.readFileSync(keyPath, 'utf8')); + if (key.asymmetricKeyType !== 'ed25519') throw new Error(`key at ${keyPath} is not an Ed25519 key`); + return key; +} + +/** Base64 of the raw 32-byte Ed25519 public key — the registry entry's `authorPublicKey`. */ +export function publicKeyBase64(privateKey: crypto.KeyObject): string { + const der = crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer; + // SPKI DER for Ed25519 is a fixed 12-byte prefix + the 32-byte key. + return der.subarray(der.length - 32).toString('base64'); +} + +/** Sign the raw artifact bytes; returns base64 of the bare 64-byte Ed25519 signature — a version's `signature`. */ +export function signArtifact(bytes: Buffer, privateKey: crypto.KeyObject): string { + return crypto.sign(null, bytes, privateKey).toString('base64'); +} + +/** Self-check: verify a signature the way the server does (bare raw Ed25519 over the bytes). */ +export function verifyArtifact(bytes: Buffer, signatureB64: string, publicKeyB64: string): boolean { + try { + const raw = Buffer.from(publicKeyB64, 'base64'); + if (raw.length !== 32) return false; + const spki = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]); + const key = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }); + const sig = Buffer.from(signatureB64, 'base64'); + if (sig.length !== 64) return false; + return crypto.verify(null, bytes, key, sig); + } catch { + return false; + } +} diff --git a/plugin-sdk/src/cli/submit.ts b/plugin-sdk/src/cli/submit.ts new file mode 100644 index 000000000..d4204438e --- /dev/null +++ b/plugin-sdk/src/cli/submit.ts @@ -0,0 +1,98 @@ +/** + * trek-plugin submit — open the TREK-Plugins registry PR for you. Forks the + * registry (once), branches off the current upstream main, writes (or merges + * into) registry/plugins/.json, commits, pushes, and opens the PR — so the + * last manual step of publishing (fork, paste JSON, open PR by hand) is gone. + * + * Requires `gh` (authenticated) and `git`, same as `release`. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins'; + +interface EntryLike { + id: string; name?: string; authorPublicKey?: string; + versions: { version: string }[]; +} + +function git(cwd: string, ...a: string[]): string { + return execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8' }).trim(); +} +function gh(...a: string[]): string { + return execFileSync('gh', a, { encoding: 'utf8' }).trim(); +} + +/** Merge a freshly-built single-version entry onto an existing registry file (update case). */ +function mergeOnto(existing: EntryLike, fresh: EntryLike): EntryLike { + const v = fresh.versions[0]; + if (existing.authorPublicKey && fresh.authorPublicKey && existing.authorPublicKey !== fresh.authorPublicKey) { + throw new Error('the signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.'); + } + if (existing.authorPublicKey && !fresh.authorPublicKey) { + throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.'); + } + const versions = [v, ...existing.versions.filter((x) => x.version !== v.version)]; + const merged: EntryLike = { ...existing, ...fresh, versions }; + merged.authorPublicKey = fresh.authorPublicKey ?? existing.authorPublicKey; + if (merged.authorPublicKey === undefined) delete merged.authorPublicKey; + return merged; +} + +export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean } = {}): { prUrl: string } { + const registry = opts.registry || DEFAULT_REGISTRY; + const name = registry.split('/')[1]; + const login = gh('api', 'user', '--jq', '.login'); + const branch = opts.branch || `plugin-${entry.id}-${entry.versions[0].version}`; + + // Ensure the fork exists (idempotent — prints "already exists" if it does). + try { gh('repo', 'fork', registry, '--clone=false'); } catch { /* already forked */ } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-submit-')); + try { + // Clone the fork (pushable via gh auth), then base a branch on the CURRENT upstream main. + let cloned = false; + for (let i = 0; i < 3 && !cloned; i++) { + try { gh('repo', 'clone', `${login}/${name}`, tmp, '--', '--depth=1'); cloned = true; } + catch { /* fork may not be ready yet; retry */ } + } + if (!cloned) throw new Error(`could not clone your fork ${login}/${name} (is the fork ready on GitHub?)`); + + git(tmp, 'remote', 'add', 'upstream', `https://github.com/${registry}.git`); + git(tmp, 'fetch', '--depth=1', 'upstream', 'main'); + git(tmp, 'checkout', '-B', branch, 'upstream/main'); + + const rel = path.join('registry', 'plugins', `${entry.id}.json`); + const abs = path.join(tmp, rel); + let toWrite = entry; + let action = 'add'; + if (fs.existsSync(abs)) { + const existing = JSON.parse(fs.readFileSync(abs, 'utf8')) as EntryLike; + toWrite = mergeOnto(existing, entry); + action = 'update'; + } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, JSON.stringify(toWrite, null, 2) + '\n'); + + const title = action === 'add' + ? `Add ${entry.name || entry.id} (${entry.versions[0].version})` + : `Update ${entry.name || entry.id} to ${entry.versions[0].version}`; + git(tmp, 'add', rel); + git(tmp, 'commit', '-m', title); + git(tmp, 'push', '--force-with-lease', 'origin', branch); + + const body = [ + `${action === 'add' ? 'New plugin' : 'Plugin update'}: **${entry.name || entry.id}** \`${entry.id}\` ${entry.versions[0].version}.`, + '', + 'Generated with `trek-plugin submit`. CI validates the tag, artifact hash, manifest parity and README.', + ].join('\n'); + const args = ['pr', 'create', '--repo', registry, '--head', `${login}:${branch}`, '--title', title, '--body', body]; + if (opts.draft) args.push('--draft'); + const prUrl = gh(...args); + return { prUrl }; + } finally { + if (!opts.keep) fs.rmSync(tmp, { recursive: true, force: true }); + } +} diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index 4d0c1b7dd..81aecf203 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -2,14 +2,22 @@ /** * `trek-plugin ` — the plugin author CLI (#plugins). * - * validate [dir] check the manifest + layout - * pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size - * entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry - * [--merge entry.json] [--out f] - * release [dir] --repo o/n --tag vX pack -> gh release -> print entry + * create [name] [--type t] [--interactive] scaffold a plugin (wizard if no name) + * dev [dir] [--port 4317] run locally with hot reload + * validate [dir] check the manifest + layout + * pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size + * keygen [--key file] create an Ed25519 signing key + * sign [zip] [--key file] print a signature + public key for an artifact + * entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry + * [--merge entry.json] [--sign [key]] [--out f] + * preflight [dir] --repo o/n --tag vX run the registry CI checks locally + * submit [dir] --repo o/n --tag vX open the registry PR for you + * [--sign [key]] [--registry o/n] [--draft] + * release [dir] --repo o/n --tag vX pack -> gh release -> print entry + * [--sign [key]] [--merge entry.json] * - * The goal: an author runs `pack` then `entry` (or a single `release`) and never - * hand-computes sha256/size/commitSha or hand-writes the registry JSON. + * The goal: create -> dev -> release/submit, and never hand-compute sha256/size/ + * commitSha or hand-write the registry JSON. */ import fs from 'node:fs'; import path from 'node:path'; @@ -17,7 +25,11 @@ import { execFileSync } from 'node:child_process'; import { validatePluginDir } from './validate.js'; import { packPluginDir } from './pack.js'; import { buildEntry } from './entry.js'; -import { scaffold } from './create.js'; +import { scaffold, interactiveScaffold } from './create.js'; +import { runDev } from './dev.js'; +import { preflight } from './preflight.js'; +import { submitEntry } from './submit.js'; +import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; const [cmd, ...args] = process.argv.slice(2); @@ -41,12 +53,27 @@ function fail(msg: string): never { const { flags, pos } = parse(args); -try { +/** --sign, --sign , or absent → the key path to sign with (or undefined). */ +function signKey(): string | undefined { + if (!flags.sign) return undefined; + return flags.sign === 'true' ? (flags.key || defaultKeyPath()) : flags.sign; +} + +async function main(): Promise { if (cmd === 'create') { const name = pos[0]; - if (!name) fail('create needs a plugin name, e.g. `create my-plugin --type widget`'); - scaffold(name, flags.type || 'integration', process.cwd()); - console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, fill in the README, then \`trek-plugin validate ${name}\`.`); + if (!name || flags.interactive) { + const created = await interactiveScaffold(process.cwd(), name); + console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${created}\`.`); + return; + } + scaffold(name, flags.type || 'integration', process.cwd(), { + author: flags.author, description: flags.description, + permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined, + }); + console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${name}\`.`); + } else if (cmd === 'dev') { + await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined }); } else if (cmd === 'validate') { const r = validatePluginDir(pos[0] || '.'); for (const w of r.warnings) console.warn('warning: ' + w); @@ -62,6 +89,19 @@ try { console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`); console.log('\nUpload this plugin.zip to your release, then run `trek-plugin entry` to generate the registry entry.'); } + } else if (cmd === 'keygen') { + const keyPath = flags.key || defaultKeyPath(); + const { publicKey } = generateKeypair(keyPath); + console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); + console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); + console.log('\nSign releases with `trek-plugin release --sign` (or `entry --sign`).'); + } else if (cmd === 'sign') { + const zip = pos[0] || 'plugin.zip'; + if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`trek-plugin pack\` first`); + const key = loadPrivateKey(flags.key || defaultKeyPath()); + const buf = fs.readFileSync(zip); + console.log(`signature: ${signArtifact(buf, key)}`); + console.log(`authorPublicKey: ${publicKeyBase64(key)}`); } else if (cmd === 'entry') { if (!flags.repo || !flags.tag) fail('entry needs --repo and --tag '); const entry = buildEntry({ @@ -69,6 +109,7 @@ try { repo: flags.repo, tag: flags.tag, zipPath: flags.zip || 'plugin.zip', commit: flags.commit, asset: flags.asset, mergePath: flags.merge, + signKeyPath: signKey(), now: new Date().toISOString(), }); const json = JSON.stringify(entry, null, 2) + '\n'; @@ -78,6 +119,28 @@ try { } else { process.stdout.write(json); } + } else if (cmd === 'preflight') { + const entry = flags.entry + ? JSON.parse(fs.readFileSync(flags.entry, 'utf8')) + : (flags.repo && flags.tag + ? buildEntry({ dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), now: new Date().toISOString() }) + : fail('preflight needs --repo --tag , or --entry ')); + console.error('Running registry CI checks over the network…\n'); + const rep = await preflight(entry, { all: !!flags.all }); + for (const p of rep.passed) console.error(' ✓ ' + p); + for (const f of rep.failures) console.error(' ✗ ' + f); + if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } + console.error('\n✓ all checks passed — this entry should sail through CI.'); + } else if (cmd === 'submit') { + if (!flags.repo || !flags.tag) fail('submit needs --repo and --tag '); + const entry = buildEntry({ + dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, + zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), + now: new Date().toISOString(), + }); + console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); + const { prUrl } = submitEntry(entry, { registry: flags.registry, branch: flags.branch, draft: !!flags.draft, keep: !!flags.keep }); + console.log(prUrl); } else if (cmd === 'release') { if (!flags.repo || !flags.tag) fail('release needs --repo and --tag '); const dir = pos[0] || '.'; @@ -86,13 +149,13 @@ try { console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`); execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' }); - const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, now: new Date().toISOString() }); - console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR):\n'); + const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, signKeyPath: signKey(), now: new Date().toISOString() }); + console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `trek-plugin submit`):\n'); process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); } else { - console.error('usage: trek-plugin [...]'); + console.error('usage: trek-plugin [...]'); process.exit(2); } -} catch (e) { - fail(e instanceof Error ? e.message : String(e)); } + +main().catch((e) => fail(e instanceof Error ? e.message : String(e))); diff --git a/plugin-sdk/src/zip.ts b/plugin-sdk/src/zip.ts index 99f312b0e..199a30174 100644 --- a/plugin-sdk/src/zip.ts +++ b/plugin-sdk/src/zip.ts @@ -29,6 +29,28 @@ function crc32(buf: Buffer): number { return (c ^ 0xffffffff) >>> 0; } +/** List the entry names of a ZIP by walking its central directory (reader for `preflight`). */ +export function listZipNames(buf: Buffer): string[] { + // Find the End-Of-Central-Directory record (scan back from the end past any comment). + let eocd = -1; + for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 0xffff; i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('not a ZIP archive (no EOCD record)'); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); // central directory offset + const names: string[] = []; + for (let i = 0; i < count; i++) { + if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('corrupt central directory'); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + names.push(buf.toString('utf8', p + 46, p + 46 + nameLen)); + p += 46 + nameLen + extraLen + commentLen; + } + return names; +} + /** Build a ZIP buffer from a flat list of file entries (no directory entries). */ export function makeZip(files: ZipInput[]): Buffer { const local: Buffer[] = []; diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 19efea515..885d1a2d1 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -6,9 +6,10 @@ import zlib from 'node:zlib'; import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js'; import { scaffold } from '../src/cli/create.js'; import { validatePluginDir } from '../src/cli/validate.js'; -import { makeZip } from '../src/zip.js'; +import { makeZip, listZipNames } from '../src/zip.js'; import { packPluginDir } from '../src/cli/pack.js'; import { buildEntry } from '../src/cli/entry.js'; +import { generateKeypair, signArtifact, publicKeyBase64, verifyArtifact, loadPrivateKey } from '../src/cli/sign.js'; /** A central-directory zip reader mirroring the TREK server's, to prove round-trip. */ function readZip(buf: Buffer): Record { @@ -104,6 +105,14 @@ describe('scaffold + validate CLIs', () => { expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template }); + it('applies author, description, and permissions from options', () => { + scaffold('opt-plug', 'integration', tmp, { author: 'Jane', description: 'Does X', permissions: ['db:own', 'db:read:trips'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'opt-plug', 'trek-plugin.json'), 'utf8')); + expect(m.author).toBe('Jane'); + expect(m.description).toBe('Does X'); + expect(m.permissions).toEqual(['db:own', 'db:read:trips']); + }); + it('rejects an invalid plugin name', () => { expect(() => scaffold('Bad Name', 'widget', tmp)).toThrow(/invalid plugin id/); }); @@ -208,3 +217,61 @@ describe('pack + entry (publishing automation)', () => { expect(merged.versions.map((v) => v.version)).toEqual(['1.0.0', '0.9.0']); }); }); + +describe('listZipNames', () => { + it('lists central-directory entries of a makeZip archive', () => { + const zip = makeZip([{ name: 'a.js', data: Buffer.from('x') }, { name: 'server/b.js', data: Buffer.from('y'.repeat(100)) }]); + expect(listZipNames(zip).sort()).toEqual(['a.js', 'server/b.js']); + }); +}); + +describe('sign + keygen (author signatures, TOFU)', () => { + const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi'); + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sign-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('keygen writes a private key and returns a schema-length public key', () => { + const keyPath = path.join(tmp, 'k.pem'); + const { publicKey } = generateKeypair(keyPath); + expect(fs.existsSync(keyPath)).toBe(true); + expect(publicKey.length).toBeGreaterThanOrEqual(40); // registry schema minLength + expect(() => generateKeypair(keyPath)).toThrow(/already exists/); // never clobbers a key + }); + + it('a signature round-trips through the server-shaped verifier', () => { + const keyPath = path.join(tmp, 'k.pem'); + generateKeypair(keyPath); + const key = loadPrivateKey(keyPath); + const bytes = Buffer.from('the exact plugin.zip bytes ' + 'x'.repeat(60)); + const sig = signArtifact(bytes, key); + expect(sig.length).toBeGreaterThanOrEqual(40); + expect(verifyArtifact(bytes, sig, publicKeyBase64(key))).toBe(true); + expect(verifyArtifact(Buffer.concat([bytes, Buffer.from('!')]), sig, publicKeyBase64(key))).toBe(false); + }); + + it('entry --sign fills signature + authorPublicKey that verify against the artifact', () => { + const out = path.join(tmp, 'plugin.zip'); + packPluginDir(koffi, out); + const keyPath = path.join(tmp, 'k.pem'); + const { publicKey } = generateKeypair(keyPath); + const entry = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: keyPath, now: '2026-07-04T00:00:00.000Z' }); + expect(entry.authorPublicKey).toBe(publicKey); + const sig = entry.versions[0].signature; + expect(sig).toBeTruthy(); + expect(verifyArtifact(fs.readFileSync(out), sig!, entry.authorPublicKey!)).toBe(true); + }); + + it('refuses to sign an update with a different key than the one already published', () => { + const out = path.join(tmp, 'plugin.zip'); + packPluginDir(koffi, out); + const key1 = path.join(tmp, 'k1.pem'); + generateKeypair(key1); + const existing = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: key1, now: '2026-07-04T00:00:00.000Z' }); + const existingPath = path.join(tmp, 'koffi.json'); + fs.writeFileSync(existingPath, JSON.stringify({ ...existing, versions: existing.versions.map((v) => ({ ...v, version: '0.9.0', gitTag: 'v0.9.0' })) })); + const key2 = path.join(tmp, 'k2.pem'); + generateKeypair(key2); + expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/); + }); +}); diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 25c59ff3d..484b5fb9e 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -9,10 +9,14 @@ child process** and reaches it only over RPC; the browser part runs in a ## Scaffold ```bash -npx trek-plugin-sdk create my-plugin --type integration|page|widget +npx trek-plugin-sdk create # interactive wizard +npx trek-plugin-sdk create my-plugin --type integration|page|widget # or direct cd my-plugin ``` +The wizard (run `create` with no name) asks for the id, type, author and +permissions; the direct form takes them as flags. + This emits: ``` @@ -23,6 +27,25 @@ my-plugin/ README.md # fill this in — the registry requires a screenshot ``` +## Run it locally with hot reload + +```bash +npx trek-plugin-sdk dev # http://localhost:4317 +``` + +`dev` loads your `server/index.js` through the same `definePlugin` contract the +host uses and gives you a **real request loop without a full TREK**: a dashboard +listing your routes, the routes served under `/api/`, your page/widget UI +at `/ui`, and a reload on every save. The injected `ctx` **enforces exactly the +permissions your manifest grants** — an ungranted call throws `PERMISSION_DENIED`, +so you catch a missing grant here rather than after install. `db:own` is backed +by a real SQLite file (`.trek-dev/db.sqlite`) when the runtime has `node:sqlite`. + +- Hit a route as an unauthenticated request with `?_anon=1` (an `auth: true` + route then returns 401, mirroring the host). +- Feed `ctx.trips` / `ctx.users` by dropping a `dev-fixtures.json` next to the + manifest: `{ "trips": { "1": { "members": [1], "data": { … } } }, "users": {} }`. + ## The three plugin types - **integration** — background logic (jobs, routes) with no UI of its own. Photo- diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index a56815b16..354e49356 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -77,7 +77,12 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]]. | Command | What it does | |---|---| +| `trek-plugin create [name]` | Scaffold a plugin. With no name it runs an interactive wizard (id, type, author, permissions); with a name it takes `--type`/`--author`/`--permissions` flags. | +| `trek-plugin dev [dir]` | Run the plugin locally with a real request loop + hot reload — no full TREK. The injected `ctx` enforces your granted permissions, `db:own` is a real SQLite file, routes serve under `/api/`, and page/widget UI at `/ui`. | | `trek-plugin validate [dir]` | Manifest + layout checks: parses the manifest with the same rules as install, requires a `README.md` (warns if it has no screenshot or still holds template placeholders) and a built `server/index.js`, and warns if the directory name ≠ the plugin id. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. | +| `trek-plugin preflight --repo --tag ` | Runs the **full** registry CI checks locally over the network (tag→commit, manifest parity, artifact sha256/size, native scan, README quality gate) against your pushed release — so you catch a CI failure before opening the PR. | +| `trek-plugin submit --repo --tag ` | Opens the registry PR for you: forks TREK-Plugins, branches off current main, writes/merges `registry/plugins/.json`, pushes, and creates the PR. Requires `gh`. | +| `trek-plugin keygen` / `sign` | `keygen` creates an Ed25519 signing key; `sign` (or `--sign` on `entry`/`release`/`submit`) signs the artifact and fills `authorPublicKey` + `signature` so TREK pins your identity (TOFU). | | `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Validates, then builds `plugin.zip` in the installer's exact layout (`trek-plugin.json`, `README.md`, `LICENSE`, `package.json` at the root; `server/` and `client/` recursed) and prints its **sha256 + byte size**. Skips `node_modules`, `.git`, `.ts` and `.map` files, and **refuses native binaries** (`.node`, `binding.gyp`, `prebuilds/`) and over-size archives, same as the installer. **`docs/` is intentionally NOT shipped** — the store fetches your screenshot from `docs/screenshot.png` in the repo. | | `trek-plugin entry --repo --tag [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and `minTrekVersion` (derived from the manifest's `trek` range, e.g. `>=3.2.0 <4.0.0` → `3.2.0`). `--merge` prepends this version onto an existing `registry/plugins/.json` for an update, keeping versions newest-first. | | `trek-plugin release [dir] --repo --tag ` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. | diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md index 028e2f5a2..71bde371d 100644 --- a/wiki/Plugin-Publishing.md +++ b/wiki/Plugin-Publishing.md @@ -11,16 +11,18 @@ the mechanical work — you rarely hand-type a hash, size, commit, or JSON field ## The short version ```bash -npx trek-plugin-sdk validate . # manifest + layout sanity -npx trek-plugin-sdk pack . # build plugin.zip, print sha256 + size -gh release create v1.0.0 plugin.zip --repo you/trek-plugin-flight-tracker -npx trek-plugin-sdk entry --repo you/trek-plugin-flight-tracker --tag v1.0.0 \ - --out registry/plugins/flight-tracker.json # the ready-to-PR entry +git tag v1.0.0 && git push --tags +npx trek-plugin-sdk release --repo you/trek-plugin-flight-tracker --tag v1.0.0 # pack → GitHub release → entry +npx trek-plugin-sdk preflight --repo you/trek-plugin-flight-tracker --tag v1.0.0 # the CI checks, locally +npx trek-plugin-sdk submit --repo you/trek-plugin-flight-tracker --tag v1.0.0 # opens the registry PR ``` -…then open a PR to **TREK-Plugins** adding that one -`registry/plugins/.json` file. `trek-plugin release` collapses the middle -three steps into one command (see below). +`release` builds the artifact, creates the GitHub release, and prints the +registry entry. `preflight` runs the exact registry CI checks against your +pushed release so you don't round-trip through review. `submit` forks +**TREK-Plugins**, writes `registry/plugins/.json`, and opens the PR for you. +Prefer to do the last step by hand? `entry --out registry/plugins/.json` +prints the file and you open the PR yourself. ## 1. Host and build your plugin @@ -113,12 +115,39 @@ npx trek-plugin-sdk release . --repo you/trek-plugin-flight-tracker --tag v1.0.0 entry to stdout. It accepts `--out`, `--notes`, `--commit`, and `--merge`. (It requires the `gh` CLI, authenticated.) -## 6. Open a registry PR +## 6. Preflight — run the CI checks locally -You don't have write access, so **fork -[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins)**, add your generated -file as `registry/plugins/.json`, and open a PR back to `main`. Add **only** -that file — `dist/` is generated on merge, and CI rejects manual edits to it. +Before you open the PR, run the exact registry CI checks against your pushed +release, so you catch what CI would reject without a review round-trip: + +```bash +npx trek-plugin-sdk preflight --repo you/trek-plugin-flight-tracker --tag v1.0.0 +``` + +`preflight` mirrors both CI scripts over the network: the tag resolves to the +pinned `commitSha`, the manifest at that commit matches the entry, the released +artifact's **sha256 + size** match and it carries **no native binaries**, and the +README passes the quality gate (required sections, real prose, a resolving +screenshot, permission parity). It also accepts `--entry ` to check a +hand-written entry. A green preflight predicts a green CI. + +## 7. Open the registry PR + +The fast path — `submit` does the whole fork/branch/commit/PR dance for you: + +```bash +npx trek-plugin-sdk submit --repo you/trek-plugin-flight-tracker --tag v1.0.0 +``` + +It forks [TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) (once), +branches off the current `main`, writes (or, for an update, merges into) +`registry/plugins/.json`, pushes, and opens the PR — printing its URL. Add +`--draft` for a draft PR, `--registry ` for a mirror. (Requires `gh`, +authenticated.) + +**By hand instead:** fork the registry, add your generated file as +`registry/plugins/.json`, and open a PR back to `main`. Add **only** that +file — `dist/` is generated on merge, and CI rejects manual edits to it. The entry follows [`schema/plugin-entry.schema.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/plugin-entry.schema.json); [`schema/example-entry.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/example-entry.json) @@ -171,16 +200,28 @@ TREK verifies the signature offline (minisign / Ed25519, no external service) an pins your key on first install (trust-on-first-use): a later release signed with a different key is refused until an admin re-trusts it. -**One-time — create a key:** +**The easy way — let the SDK do it** (dependency-free Ed25519, no minisign needed): + +```bash +npx trek-plugin-sdk keygen # once: writes ~/.trek-plugin/signing.key +npx trek-plugin-sdk release --repo you/repo --tag v1.2.0 --sign +npx trek-plugin-sdk submit --repo you/repo --tag v1.2.0 --sign +``` + +`--sign` signs the exact artifact bytes and fills both `authorPublicKey` (entry) +and `signature` (version) for you; `submit --sign` even refuses to publish an +update signed with a *different* key than the one already listed, so you can't +lock yourself out by accident. **Back up `~/.trek-plugin/signing.key`** — losing +it means you can't ship signed updates. + +**By hand with minisign**, if you prefer: ```bash minisign -G # writes minisign.key (keep secret) + minisign.pub ``` Put the base64 payload line from `minisign.pub` in your entry as -`authorPublicKey` (stable across versions). - -**Each release — sign the artifact:** +`authorPublicKey` (stable across versions), then per release: ```bash minisign -Sm plugin.zip # writes plugin.zip.minisig