feat(plugin-sdk): one-command publishing — pack, entry, release

Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:

- `trek-plugin pack` builds plugin.zip in the exact layout the installer
  reads (own tiny zip writer, so the SDK stays dependency-free and the
  format can't drift from the reader), enforces the same native-binary and
  size rules, and prints the sha256 + size. docs/ is left out — the store
  fetches the screenshot from the repo, so it doesn't belong in the install
  artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
  + the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
  size, and minTrekVersion derived from the manifest's trek range. `--merge`
  prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.

Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.
This commit is contained in:
Maurice
2026-07-04 13:57:45 +02:00
committed by Maurice
parent bc56c2dcc1
commit c3c70bcf9d
7 changed files with 491 additions and 20 deletions
+6 -3
View File
@@ -81,7 +81,7 @@ function readme(name: string): string {
> One sentence: what this plugin does.
![screenshot](./docs/screenshot-1.png)
![screenshot](./docs/screenshot.png)
## What it does
@@ -89,7 +89,9 @@ Describe the feature this plugin adds to TREK.
## Screenshots
Show it in context (at least one image).
Show it in context. Commit a \`docs/screenshot.png\` — it's what the store card
shows. A 16:9 image (e.g. 1600×900) with your plugin centred and some margin
looks best (the card crops the edges).
## Permissions
@@ -103,7 +105,8 @@ How to configure it.
## License
MIT
Your plugin is your own code — license it however you like; TREK does not impose
one. Replace this line with your license (for example, MIT).
`;
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip]
* [--merge registry/plugins/<id>.json] [--out file]
*
* Generates the ready-to-PR TREK-Plugins registry entry from the manifest + the
* packed plugin.zip + the git tag — so the sha256, size, commitSha, downloadUrl
* and minTrekVersion an author would compute by hand all come out of one command.
* With --merge it prepends the new version onto an existing entry (the update
* case), keeping versions newest-first.
*/
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
interface Version {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; minTrekVersion: string; size: number; apiVersion: number;
nativeModules: false; publishedAt: string;
}
interface Entry {
id: string; name: string; author: string; description: string; repo: string;
homepage?: string; tags?: string[]; type: string; versions: Version[];
}
/** Lower bound of a `trek` range like ">=3.2.0 <4.0.0" -> "3.2.0" (matches the server). */
function minTrekFrom(trek: unknown): string | null {
if (typeof trek !== 'string') return null;
return trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null;
}
function resolveCommit(dir: string, tag: string, override?: string): string {
if (override) return override;
try {
// ^{commit} dereferences an annotated tag to its commit.
return execFileSync('git', ['-C', dir, 'rev-parse', `${tag}^{commit}`], { encoding: 'utf8' }).trim();
} catch {
throw new Error(`could not resolve the commit for tag "${tag}" (is it pushed?). Pass --commit <sha> to override.`);
}
}
export function buildEntry(opts: {
dir: string; repo: string; tag: string; zipPath: string;
commit?: string; asset?: string; mergePath?: string; now: 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}"`);
const minTrek = minTrekFrom(manifest.trek);
if (!minTrek) throw new Error('manifest has no "trek" version range to derive minTrekVersion from (e.g. "trek": ">=3.2.0 <4.0.0")');
if (!fs.existsSync(opts.zipPath)) throw new Error(`artifact not found: ${opts.zipPath} — run \`trek-plugin pack\` first`);
const buf = fs.readFileSync(opts.zipPath);
const asset = opts.asset || path.basename(opts.zipPath);
const version: Version = {
version: String(manifest.version),
gitTag: opts.tag,
commitSha: resolveCommit(opts.dir, opts.tag, opts.commit),
downloadUrl: `https://github.com/${opts.repo}/releases/download/${opts.tag}/${asset}`,
sha256: createHash('sha256').update(buf).digest('hex'),
minTrekVersion: minTrek,
size: buf.length,
apiVersion: typeof manifest.apiVersion === 'number' ? manifest.apiVersion : 1,
nativeModules: false,
publishedAt: opts.now,
};
if (opts.mergePath) {
const existing = JSON.parse(fs.readFileSync(opts.mergePath, 'utf8')) as Entry;
const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)];
return { ...existing, versions };
}
const entry: Entry = {
id: String(manifest.id),
name: String(manifest.name),
author: typeof manifest.author === 'string' ? manifest.author : 'Unknown',
description: typeof manifest.description === 'string' ? manifest.description : '',
repo: opts.repo,
type: String(manifest.type),
versions: [version],
};
if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage;
if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8);
return entry;
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* trek-plugin pack [dir] [--out plugin.zip] [--json]
*
* Turns a built plugin directory into the exact plugin.zip the TREK installer
* expects, and prints its sha256 + byte size — the two values authors otherwise
* compute by hand for the registry entry. Validates the manifest first, refuses
* native binaries (same rule as the installer), and enforces the same size
* limits, so a local pack that succeeds installs cleanly.
*/
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { makeZip, type ZipInput } from '../zip.js';
import { validatePluginDir } from './validate.js';
const MAX_TOTAL = 50 * 1024 * 1024;
const MAX_FILE = 25 * 1024 * 1024;
const MAX_ENTRIES = 4000;
// Files kept at the archive root; everything the installer needs plus light docs.
const ROOT_FILES = ['trek-plugin.json', 'README.md', 'LICENSE', 'LICENSE.md', 'package.json'];
// Directories walked recursively into the archive.
const DIRS = ['server', 'client'];
// Never shipped: dep trees, VCS, source maps, TypeScript sources, the output zip.
const SKIP_DIR = new Set(['node_modules', '.git']);
const NATIVE_RE = /\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
export interface PackResult {
artifact: string;
sha256: string;
size: number;
files: string[];
}
function walk(base: string, rel: string, out: ZipInput[]): void {
for (const entry of fs.readdirSync(path.join(base, rel), { withFileTypes: true })) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (SKIP_DIR.has(entry.name)) continue;
walk(base, childRel, out);
} else if (entry.isFile()) {
if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue;
out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) });
}
// symlinks and other non-regular entries are skipped (never shipped)
}
}
export function packPluginDir(dir: string, outPath: string): PackResult {
const report = validatePluginDir(dir);
if (!report.ok) throw new Error('plugin is not valid:\n - ' + report.errors.join('\n - '));
const outAbs = path.resolve(outPath);
const files: ZipInput[] = [];
for (const f of ROOT_FILES) {
const p = path.join(dir, f);
if (fs.existsSync(p) && fs.statSync(p).isFile()) files.push({ name: f, data: fs.readFileSync(p) });
}
for (const d of DIRS) {
if (fs.existsSync(path.join(dir, d))) walk(dir, d, files);
}
// Drop an existing output zip if it happens to sit in the tree.
const filtered = files.filter((f) => path.resolve(dir, f.name) !== outAbs);
// Same gates the installer enforces, surfaced locally.
let total = 0;
for (const f of filtered) {
if (NATIVE_RE.test(f.name)) throw new Error(`native binaries are not allowed: ${f.name}`);
if (f.data.length > MAX_FILE) throw new Error(`file too large (>25MB): ${f.name}`);
total += f.data.length;
}
if (total > MAX_TOTAL) throw new Error('archive exceeds the 50MB limit');
if (filtered.length > MAX_ENTRIES) throw new Error('too many files (>4000)');
if (!filtered.some((f) => f.name === 'trek-plugin.json')) throw new Error('trek-plugin.json must be at the plugin root');
const buf = makeZip(filtered);
fs.writeFileSync(outAbs, buf);
return {
artifact: outAbs,
sha256: createHash('sha256').update(buf).digest('hex'),
size: buf.length,
files: filtered.map((f) => f.name).sort(),
};
}
+86 -13
View File
@@ -1,19 +1,92 @@
#!/usr/bin/env node
/** `trek-plugin <command>` dispatcher (#plugins, M6). Commands: validate. */
/**
* `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
*
* 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.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { validatePluginDir } from './validate.js';
import { packPluginDir } from './pack.js';
import { buildEntry } from './entry.js';
const [cmd, ...rest] = process.argv.slice(2);
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'validate') {
const r = validatePluginDir(rest[0] || '.');
for (const w of r.warnings) console.warn('warning: ' + w);
if (r.ok) {
console.log('✓ plugin is valid');
} else {
for (const e of r.errors) console.error('error: ' + e);
process.exit(1);
function parse(a: string[]): { flags: Record<string, string>; pos: string[] } {
const flags: Record<string, string> = {};
const pos: string[] = [];
for (let i = 0; i < a.length; i++) {
const t = a[i];
if (t.startsWith('--')) {
const next = a[i + 1];
flags[t.slice(2)] = next !== undefined && !next.startsWith('--') ? (i++, next) : 'true';
} else pos.push(t);
}
} else {
console.error('usage: trek-plugin validate [dir]');
process.exit(2);
return { flags, pos };
}
function fail(msg: string): never {
console.error('error: ' + msg);
process.exit(1);
}
const { flags, pos } = parse(args);
try {
if (cmd === 'validate') {
const r = validatePluginDir(pos[0] || '.');
for (const w of r.warnings) console.warn('warning: ' + w);
if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); }
console.log('✓ plugin is valid');
} else if (cmd === 'pack') {
const r = packPluginDir(pos[0] || '.', flags.out || 'plugin.zip');
if (flags.json) {
console.log(JSON.stringify(r, null, 2));
} else {
console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`);
for (const f of r.files) console.log(' ' + f);
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 === 'entry') {
if (!flags.repo || !flags.tag) fail('entry needs --repo <owner/name> and --tag <vX.Y.Z>');
const entry = buildEntry({
dir: flags.dir || pos[0] || '.',
repo: flags.repo, tag: flags.tag,
zipPath: flags.zip || 'plugin.zip',
commit: flags.commit, asset: flags.asset, mergePath: flags.merge,
now: new Date().toISOString(),
});
const json = JSON.stringify(entry, null, 2) + '\n';
if (flags.out) {
fs.writeFileSync(flags.out, json);
console.error(`Wrote ${flags.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`);
} else {
process.stdout.write(json);
}
} else if (cmd === 'release') {
if (!flags.repo || !flags.tag) fail('release needs --repo <owner/name> and --tag <vX.Y.Z>');
const dir = pos[0] || '.';
const zip = path.resolve(dir, flags.out || 'plugin.zip');
const packed = packPluginDir(dir, zip);
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');
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
} else {
console.error('usage: trek-plugin <validate|pack|entry|release> [...]');
process.exit(2);
}
} catch (e) {
fail(e instanceof Error ? e.message : String(e));
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Minimal, dependency-free ZIP writer (#plugins, M6+). Produces a stored-metadata
* + deflate ZIP whose central directory matches exactly what the TREK installer's
* reader (server safe-extract.ts) parses: EOCD -> central headers -> local headers.
* Kept tiny and in-tree so the SDK stays dependency-free and the artifact format
* can never drift from the reader.
*/
import zlib from 'node:zlib';
export interface ZipInput {
/** Archive-relative path, forward slashes (e.g. "server/index.js"). */
name: string;
data: Buffer;
}
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = (CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)) >>> 0;
return (c ^ 0xffffffff) >>> 0;
}
/** Build a ZIP buffer from a flat list of file entries (no directory entries). */
export function makeZip(files: ZipInput[]): Buffer {
const local: Buffer[] = [];
const central: Buffer[] = [];
let offset = 0;
for (const f of files) {
const nameBuf = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
const crc = crc32(f.data);
// Deflate; fall back to stored if that somehow grows the data.
const deflated = zlib.deflateRawSync(f.data, { level: 9 });
const useStore = deflated.length >= f.data.length;
const method = useStore ? 0 : 8;
const body = useStore ? f.data : deflated;
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(0x04034b50, 0); // local file header signature
lfh.writeUInt16LE(20, 4); // version needed
lfh.writeUInt16LE(0, 6); // flags
lfh.writeUInt16LE(method, 8);
lfh.writeUInt16LE(0, 10); // mod time
lfh.writeUInt16LE(0x21, 12); // mod date (fixed, 1980-01-01)
lfh.writeUInt32LE(crc, 14);
lfh.writeUInt32LE(body.length, 18); // compressed size
lfh.writeUInt32LE(f.data.length, 22); // uncompressed size
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra length
local.push(lfh, nameBuf, body);
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // central header signature
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // flags
cdh.writeUInt16LE(method, 10);
cdh.writeUInt16LE(0, 12); // mod time
cdh.writeUInt16LE(0x21, 14); // mod date
cdh.writeUInt32LE(crc, 16);
cdh.writeUInt32LE(body.length, 20);
cdh.writeUInt32LE(f.data.length, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra length
cdh.writeUInt16LE(0, 32); // comment length
cdh.writeUInt16LE(0, 34); // disk number
cdh.writeUInt16LE(0, 36); // internal attributes
cdh.writeUInt32LE(0, 38); // external attributes (0 = regular file, never symlink)
cdh.writeUInt32LE(offset, 42); // local header offset
central.push(cdh, nameBuf);
offset += lfh.length + nameBuf.length + body.length;
}
const centralBuf = Buffer.concat(central);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // EOCD signature
eocd.writeUInt16LE(0, 4); // this disk
eocd.writeUInt16LE(0, 6); // central dir start disk
eocd.writeUInt16LE(files.length, 8); // entries on this disk
eocd.writeUInt16LE(files.length, 10); // total entries
eocd.writeUInt32LE(centralBuf.length, 12); // central dir size
eocd.writeUInt32LE(offset, 16); // central dir offset
eocd.writeUInt16LE(0, 20); // comment length
return Buffer.concat([...local, centralBuf, eocd]);
}