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
+29 -4
View File
@@ -45,16 +45,41 @@ const { ctx, broadcasts } = createMockHost({
// degrades gracefully when a permission is missing.
```
## Publish
The SDK does the fiddly parts (zipping, hashing, sizing, writing the registry
entry) so you don't compute anything by hand:
```bash
npx trek-plugin validate # manifest + layout OK?
npx trek-plugin pack # -> plugin.zip, prints sha256 + size
gh release create v1.0.0 plugin.zip # attach the artifact to your tag
npx trek-plugin entry --repo you/repo --tag v1.0.0
# -> the ready-to-PR registry entry JSON
```
Or in one step: `npx trek-plugin 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`.
**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.
## Exports
- `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
- `validateManifest(json)` — the same checks the registry CI runs.
- `validateManifest(json)` — the manifest rules the server loader uses.
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
## CLIs
- `create-trek-plugin <name> --type …` — scaffold.
- `trek-plugin validate [dir]` — validate the manifest + README locally (predicts the registry CI result).
- `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.
MIT.
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.
+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]);
}
+103
View File
@@ -2,9 +2,36 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 { packPluginDir } from '../src/cli/pack.js';
import { buildEntry } from '../src/cli/entry.js';
/** A central-directory zip reader mirroring the TREK server's, to prove round-trip. */
function readZip(buf: Buffer): Record<string, Buffer> {
let e = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { e = i; break; }
const count = buf.readUInt16LE(e + 8);
let p = buf.readUInt32LE(e + 16);
const out: Record<string, Buffer> = {};
for (let i = 0; i < count; i++) {
const method = buf.readUInt16LE(p + 10);
const compSize = buf.readUInt32LE(p + 20);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const lho = buf.readUInt32LE(p + 42);
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
const dataStart = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
const comp = buf.subarray(dataStart, dataStart + compSize);
out[name] = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
p += 46 + nameLen + extraLen + commentLen;
}
return out;
}
describe('definePlugin + api version', () => {
it('returns the definition and exposes the api version', () => {
@@ -105,3 +132,79 @@ describe('reference plugin (examples/koffi)', () => {
expect(manifest.capabilities?.widget?.slot).toBe('hero');
});
});
describe('makeZip', () => {
it('round-trips through a central-directory reader (installer-compatible)', () => {
const files = [
{ name: 'trek-plugin.json', data: Buffer.from('{"id":"x"}') },
{ name: 'server/index.js', data: Buffer.from('module.exports={}\n'.repeat(200)) },
];
const zip = makeZip(files);
expect(zip.subarray(0, 2).toString()).toBe('PK'); // local file header magic
const back = readZip(zip);
expect(Object.keys(back).sort()).toEqual(['server/index.js', 'trek-plugin.json']);
expect(back['trek-plugin.json'].toString()).toBe('{"id":"x"}');
expect(back['server/index.js'].length).toBe(files[1].data.length);
});
});
describe('pack + entry (publishing automation)', () => {
const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('packs the canonical layout, excludes docs/, and reports sha256 + size', () => {
const out = path.join(tmp, 'plugin.zip');
const r = packPluginDir(koffi, out);
expect(r.files).toEqual(['README.md', 'client/index.html', 'server/index.js', 'trek-plugin.json']);
expect(r.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(r.size).toBeGreaterThan(0);
const back = readZip(fs.readFileSync(out));
expect(back['trek-plugin.json']).toBeTruthy();
expect(Object.keys(back).some((n) => n.startsWith('docs/'))).toBe(false); // screenshot served from repo, not shipped
});
it('refuses a plugin that ships a native binary', () => {
const bad = path.join(tmp, 'bad');
fs.mkdirSync(path.join(bad, 'server'), { recursive: true });
fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [] }));
fs.writeFileSync(path.join(bad, 'server', 'index.js'), 'module.exports={}');
fs.writeFileSync(path.join(bad, 'server', 'thing.node'), Buffer.from([1, 2, 3]));
fs.writeFileSync(path.join(bad, 'README.md'), '# Bad\n![x](x.png)\ncontent');
expect(() => packPluginDir(bad, path.join(tmp, 'x.zip'))).toThrow(/native binaries/);
});
it('builds a registry entry with sha256/size/commit/minTrekVersion filled in', () => {
const out = path.join(tmp, 'plugin.zip');
const packed = packPluginDir(koffi, out);
const entry = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
});
expect(entry.id).toBe('koffi');
expect(entry.type).toBe('widget');
const v = entry.versions[0];
expect(v.sha256).toBe(packed.sha256);
expect(v.size).toBe(packed.size);
expect(v.commitSha).toBe('a'.repeat(40));
expect(v.minTrekVersion).toBe('3.2.0');
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/trek-plugin-koffi/releases/download/v1.0.0/plugin.zip');
expect(v.nativeModules).toBe(false);
});
it('--merge prepends the new version onto an existing entry, newest-first', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const existingPath = path.join(tmp, 'koffi.json');
fs.writeFileSync(existingPath, JSON.stringify({
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', repo: 'mauriceboe/trek-plugin-koffi', type: 'widget',
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://github.com/x/y/releases/download/v0.9.0/plugin.zip', sha256: 'c'.repeat(64), minTrekVersion: '3.2.0', size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
}));
const merged = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), mergePath: existingPath, now: '2026-07-04T00:00:00.000Z',
});
expect(merged.versions.map((v) => v.version)).toEqual(['1.0.0', '0.9.0']);
});
});