feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard

Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.

- `dev`: run a plugin locally with a real request loop and hot reload — no full
  TREK. Injects a ctx that enforces the manifest's granted permissions (an
  ungranted call throws, so you catch a missing grant), backs db:own with a real
  SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
  and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
  manifest parity, artifact sha256/size, native scan, README quality gate) so a
  green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
  push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
  author signatures over the artifact bytes, verified 1:1 against the server's
  TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.

24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).
This commit is contained in:
Maurice
2026-07-04 17:07:15 +02:00
committed by Maurice
parent 39f27ef55d
commit cf9be554ac
14 changed files with 962 additions and 66 deletions
+64 -22
View File
@@ -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/<id>.json`.
`submit` forks [TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins),
branches off the current main, writes (or merges into) `registry/plugins/<id>.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/<id>.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 <name> --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 <command>` (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.
+1 -1
View File
@@ -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",
+44 -4
View File
@@ -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<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.',
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<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string, def?: string) =>
new Promise<string>((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);
+246
View File
@@ -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<string, unknown>;
trips?: Record<number, { members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[] }>;
users?: Record<number, unknown>;
}
interface PluginRouteLike { method: string; path: string; auth?: boolean; handler: (req: unknown, ctx: unknown) => Promise<{ status: number; headers?: Record<string, string>; 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<string>();
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<unknown[]>; exec(sql: string, ...a: unknown[]): Promise<{ changes: number }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>; }
function createDevContext(id: string, grants: Set<string>, 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<void> {
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<string, unknown>;
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<void> {
const url = new URL(request.url || '/', 'http://localhost');
const send = (status: number, body: string, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
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('</body>', `${LIVE_RELOAD}</body>`);
return send(200, data.toString(), type);
}
// Plugin API routes mounted under /api/<route path>
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<string, unknown> = {}; 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<void>((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<unknown> {
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<string, string>)[ext] || 'application/octet-stream';
}
const LIVE_RELOAD = `<script>let __v;setInterval(async()=>{try{const v=await(await fetch('/__dev/version')).text();if(__v&&v!==__v)location.reload();__v=v;}catch(e){}},1000)</script>`;
function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number): string {
const rows = routes.length
? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('')
: '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>';
const ui = type !== 'integration' ? `<p><a href="/ui">▸ open your plugin UI (/ui)</a></p>` : '';
return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title>
<style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111}
h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px}
table{border-collapse:collapse;width:100%;margin:1rem 0}td{border-bottom:1px solid #eee;padding:.5rem .4rem;text-align:left}
.muted{color:#6b7280;font-size:.9rem}a{color:#4f46e5}@media(prefers-color-scheme:dark){body{background:#111827;color:#e5e7eb}code{background:#1f2937}td{border-color:#374151}}</style>
<h1>${id} <span class="muted">· ${type}</span></h1>
<p class="muted">${dbNote} · ${bcasts} ws broadcast(s) captured</p>
${ui}
<table><tr><th>Method</th><th>URL</th><th>Auth</th></tr>${rows}</table>
<p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p>
${LIVE_RELOAD}`;
}
+21 -3
View File
@@ -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<string, unknown>;
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;
}
+160
View File
@@ -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<PreflightReport> {
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<string, string> = { '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<string, unknown>;
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<void> {
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(/<!--[\s\S]*?-->/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(/<img[^>]+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`);
}
+71
View File
@@ -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 <file>)`);
}
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;
}
}
+98
View File
@@ -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/<id>.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 });
}
}
+80 -17
View File
@@ -2,14 +2,22 @@
/**
* `trek-plugin <command>` — 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 <keyfile>, 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<void> {
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 <owner/name> and --tag <vX.Y.Z>');
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 <owner/name> --tag <vX>, or --entry <file.json>'));
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 <owner/name> and --tag <vX.Y.Z>');
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 <owner/name> and --tag <vX.Y.Z>');
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 <create|validate|pack|entry|release> [...]');
console.error('usage: trek-plugin <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release> [...]');
process.exit(2);
}
} catch (e) {
fail(e instanceof Error ? e.message : String(e));
}
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));
+22
View File
@@ -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[] = [];
+68 -1
View File
@@ -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<string, Buffer> {
@@ -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/);
});
});
+24 -1
View File
@@ -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/<path>`, 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-
+5
View File
@@ -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/<path>`, 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 <o/n> --tag <vX>` | 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 <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, branches off current main, writes/merges `registry/plugins/<id>.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 <owner/name> --tag <vX.Y.Z> [--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/<id>.json` for an update, keeping versions newest-first. |
| `trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z>` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. |
+58 -17
View File
@@ -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/<id>.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/<id>.json`, and opens the PR for you.
Prefer to do the last step by hand? `entry --out registry/plugins/<id>.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/<id>.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 <file.json>` 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/<id>.json`, pushes, and opens the PR — printing its URL. Add
`--draft` for a draft PR, `--registry <owner/name>` for a mirror. (Requires `gh`,
authenticated.)
**By hand instead:** fork the registry, add your generated file as
`registry/plugins/<id>.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