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
+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)));