diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index adbd7da3b..342975044 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -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/.json`. + +**Updating** an already-listed plugin: bump `version`, tag, and pass +`--merge registry/plugins/.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 --type …` — scaffold. -- `trek-plugin validate [dir]` — validate the manifest + README locally (predicts the registry CI result). +- `create-trek-plugin --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. diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index a60ea9053..f6fcd9762 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -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). `; } diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts new file mode 100644 index 000000000..0d742872d --- /dev/null +++ b/plugin-sdk/src/cli/entry.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * trek-plugin entry --repo --tag [--zip plugin.zip] + * [--merge registry/plugins/.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 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; + 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; +} diff --git a/plugin-sdk/src/cli/pack.ts b/plugin-sdk/src/cli/pack.ts new file mode 100644 index 000000000..02d215d3e --- /dev/null +++ b/plugin-sdk/src/cli/pack.ts @@ -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(), + }; +} diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index fc1294673..8a2cab05f 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -1,19 +1,92 @@ #!/usr/bin/env node -/** `trek-plugin ` dispatcher (#plugins, M6). Commands: validate. */ +/** + * `trek-plugin ` — 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; pos: string[] } { + const flags: Record = {}; + 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 and --tag '); + 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 and --tag '); + 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 [...]'); + process.exit(2); + } +} catch (e) { + fail(e instanceof Error ? e.message : String(e)); } diff --git a/plugin-sdk/src/zip.ts b/plugin-sdk/src/zip.ts new file mode 100644 index 000000000..99f312b0e --- /dev/null +++ b/plugin-sdk/src/zip.ts @@ -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]); +} diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 362c0c703..19efea515 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -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 { + 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 = {}; + 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']); + }); +});