mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
+64
-22
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user