Files
TREK/plugin-sdk/src/cli/entry.ts
T
2026-08-28 00:56:44 +02:00

184 lines
9.5 KiB
JavaScript

#!/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 and downloadUrl
* 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';
import { minVersion, validRange } from 'semver';
import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js';
import { readJsonFile } from './json.js';
interface Version {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string;
/**
* The manifest's `trek` range, verbatim — the ONE compatibility field an entry carries.
* It is what TREK gates installs and activation on, and the only field that can express
* an exclusive upper bound.
*
* There is deliberately no `minTrekVersion` here any more. It said the same thing (the
* range's lower bound) in a weaker form, and a second field restating a derived fact is a
* second field that can be wrong. The registry still ACCEPTS one on entries published
* before `trek` existed — and checks it agrees with the range — but nothing emits one now.
*/
trek: string;
size: number; apiVersion: number;
nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string;
/**
* The manifest's dependency fields, mirrored.
*
* These are NOT optional decoration. TREK resolves requiredAddons and pluginDependencies from
* the registry INDEX, before it ever downloads the artifact — so an entry that omits them
* resolves against a dependency set the code does not actually have. Both the registry's CI
* (validate-entry.mjs) and `preflight` parity-check them against the manifest at the pinned
* commit, which means an entry built without them fails for any plugin that declares one.
*
* They were missing here until now, so `requiredAddons: ["budget"]` in a manifest produced an
* entry CI rejected with "manifest requiredAddons != entry requiredAddons" — after the release
* had been cut and its bytes pinned. Emitted only when non-empty, so ordinary entries are
* byte-identical to before.
*/
requiredAddons?: string[]; pluginDependencies?: PluginDependency[];
}
interface PluginDependency { id: string; version: string }
interface Entry {
id: string; name: string; author: string; description: string; repo: string;
homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[];
}
/**
* Lower bound of a `trek` range like ">=4.0.0 <5.0.0" -> "4.0.0" (matches the server).
*
* Read off the range with semver rather than by finding the first version-shaped substring:
* for "<4.0.0" the first such substring is 4.0.0, which is the range's *upper* bound and
* would have been published as the minimum — the precise inverse of what the plugin says.
*/
function minTrekFrom(trek: unknown): string | null {
if (typeof trek !== 'string' || validRange(trek) === null) return null;
try {
return minVersion(trek)?.version ?? null;
} catch {
return 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;
/** Optional Ed25519 private-key file — signs the artifact and pins the author key. */
signKeyPath?: string;
/**
* Declare that a signing key DIFFERENT from the published one is a deliberate rotation, not an
* accident. Merging still needs a registry maintainer's `allow-key-change` label, and every
* admin who has the plugin must re-trust the new key.
*/
allowKeyChange?: boolean;
}): Entry {
const manifest = readJsonFile<Record<string, unknown>>(path.join(opts.dir, 'trek-plugin.json'));
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
// minVersion() is null for a range nothing can satisfy (">=4.0.0 <3.0.0" parses fine), so
// this doubles as the satisfiability check — TREK would refuse to install such a plugin.
if (!minTrekFrom(manifest.trek)) throw new Error('manifest has no valid "trek" version range (e.g. "trek": ">=4.0.0 <5.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'),
trek: String(manifest.trek),
size: buf.length,
apiVersion: typeof manifest.apiVersion === 'number' ? manifest.apiVersion : 1,
nativeModules: false,
publishedAt: opts.now,
};
// Mirror the manifest's operatorEgress onto the entry — CI parity-checks it, because the
// flag says the egress[] list is NOT the plugin's full network reach (an admin may add
// hosts after install). Only emitted when true, so ordinary entries stay unchanged.
if (manifest.operatorEgress === true) version.operatorEgress = true;
// Mirror the dependency fields. See the Version interface for why omitting these was a bug:
// TREK resolves them from the index before downloading, and CI refuses an entry that
// disagrees with the manifest. Only emitted when non-empty — an empty array and an absent
// one normalise identically on both sides of the parity check.
const requiredAddons = Array.isArray(manifest.requiredAddons) ? manifest.requiredAddons.map(String) : [];
if (requiredAddons.length) version.requiredAddons = requiredAddons;
const pluginDependencies = Array.isArray(manifest.pluginDependencies)
? (manifest.pluginDependencies as PluginDependency[]).map((d) => ({ id: String(d?.id), version: String(d?.version) }))
: [];
if (pluginDependencies.length) version.pluginDependencies = pluginDependencies;
// Optional author signature over the exact artifact bytes.
let authorPublicKey: string | undefined;
if (opts.signKeyPath) {
const key = loadPrivateKey(opts.signKeyPath);
version.signature = signArtifact(buf, key);
authorPublicKey = publicKeyBase64(key);
}
if (opts.mergePath) {
const existing = readJsonFile<Entry>(opts.mergePath);
const keyChanged = !!authorPublicKey && !!existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey;
if (keyChanged && !opts.allowKeyChange) {
throw new Error(
'this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.\n' +
'If you MEAN to rotate the key, pass --allow-key-change (the registry PR then needs a maintainer\'s allow-key-change label, and every admin must re-trust the plugin).',
);
}
if (existing.authorPublicKey && !authorPublicKey) {
throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.');
}
let olderVersions = existing.versions.filter((v) => v.version !== version.version);
if (keyChanged) {
// A deliberate rotation. The older versions' signatures were made with the OLD key — under
// the new authorPublicKey they are exactly the "does not verify" failure preflight rejects.
// Strip them: the retro-sign pass then re-signs each pinned artifact with the new key, which
// is the only way a rotated entry is internally consistent.
olderVersions = olderVersions.map(({ signature: _dropped, ...v }) => v as Version);
}
const versions = [version, ...olderVersions];
// Refresh the icon from the manifest when this release declares one (an author who adds
// or changes it should see it in the store), but never wipe an icon the entry already
// carries just because the manifest omits it.
const icon = typeof manifest.icon === 'string' && manifest.icon ? manifest.icon : existing.icon;
return { ...existing, icon, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, 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.icon === 'string' && manifest.icon) entry.icon = manifest.icon;
if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage;
if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8);
if (authorPublicKey) entry.authorPublicKey = authorPublicKey;
return entry;
}