mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user