mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
+29
-4
@@ -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.
|
||||
|
||||
@@ -81,7 +81,7 @@ function readme(name: string): string {
|
||||
|
||||
> One sentence: what this plugin does.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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\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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user