mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0
This commit is contained in:
@@ -106,7 +106,7 @@ const manifestTrekRange: OfflineCheck = {
|
||||
? fail(
|
||||
`trek: "${trek}"`,
|
||||
'This claims support for every TREK version, including ones that do not exist yet. It opts you out of the\n' +
|
||||
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=3.4.0 <4.0.0".',
|
||||
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=4.0.0 <5.0.0".',
|
||||
)
|
||||
: pass(trek);
|
||||
},
|
||||
|
||||
@@ -57,6 +57,23 @@ export interface ScaffoldOptions {
|
||||
|
||||
export const TEMPLATES = ['blank', 'notification-channel'] as const;
|
||||
|
||||
/**
|
||||
* The credential fields the OAuth broker (server-side `oauth.getToken`) needs to run the
|
||||
* authorization-code flow on the plugin's behalf. ALL `scope: 'instance'` — unlike the
|
||||
* notification-channel template's `scope: 'user'` settings above, these are configured ONCE
|
||||
* per install by an admin, not per recipient: the broker itself reads them at token-exchange
|
||||
* time, there is no per-user client id/secret. Deliberately not user-scoped.
|
||||
*/
|
||||
const OAUTH_SETTINGS: Array<{
|
||||
key: string; label: string; input_type: string; required: boolean; secret?: boolean; scope: 'instance';
|
||||
}> = [
|
||||
{ key: 'oauth_authorize_url', label: 'OAuth authorize URL', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_token_url', label: 'OAuth token URL', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_scopes', label: 'OAuth scopes (space-separated)', input_type: 'text', required: false, scope: 'instance' },
|
||||
{ key: 'oauth_client_id', label: 'OAuth client id', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_client_secret', label: 'OAuth client secret', input_type: 'text', required: true, secret: true, scope: 'instance' },
|
||||
];
|
||||
|
||||
/** Mirrors the server's widget slots (and the manifest validator's). */
|
||||
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
|
||||
export type WidgetSlot = (typeof WIDGET_SLOTS)[number];
|
||||
@@ -121,11 +138,13 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
|
||||
author: opts.author || 'Your Name',
|
||||
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
|
||||
type,
|
||||
// 3.4.0, not 3.3.0. The `ctx` namespaces this scaffold is written against — ctx.meta,
|
||||
// ctx.places, ctx.days, ctx.itinerary, ctx.costs, ctx.packing, ctx.files — only exist from
|
||||
// 3.4.0. Claiming 3.3.0 lets the plugin install on a host where they are `undefined`, which
|
||||
// fails at the first call with a TypeError instead of a clear "incompatible" at install.
|
||||
trek: '>=3.4.0 <4.0.0',
|
||||
// Floor 4.0.0: the host surface this scaffold is written against — the enforced
|
||||
// apiVersion gate, the generated core-event catalog (EVENT_FAMILIES /
|
||||
// EVENT_SNAPSHOT_GRANT), the OAuth-broker settings contract — is the TREK 4 surface.
|
||||
// Claiming an older floor lets the plugin install on a host where parts of it are
|
||||
// missing, which fails at the first call instead of a clear "incompatible" at install.
|
||||
// Ceiling 5.0.0: bounded, per the trek-range rule; widen only against a tested host.
|
||||
trek: '>=4.0.0 <5.0.0',
|
||||
nativeModules: false,
|
||||
permissions: perms,
|
||||
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
|
||||
@@ -183,6 +202,16 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
|
||||
},
|
||||
];
|
||||
}
|
||||
// The broker (ctx.oauth.getToken) needs somewhere to read the authorize/token URLs and the
|
||||
// app's client id/secret — an admin configures them once, instance-wide, and the broker
|
||||
// reads them at token-exchange time. Merge with any settings the template above already
|
||||
// wrote (a channel template never grants oauth:client today, so there is no real overlap,
|
||||
// but keys are deduped anyway rather than trusting that stays true).
|
||||
if (perms.includes('oauth:client')) {
|
||||
const existing = (manifest.settings as Array<{ key: string }> | undefined) ?? [];
|
||||
const seen = new Set(existing.map((s) => s.key));
|
||||
manifest.settings = [...existing, ...OAUTH_SETTINGS.filter((s) => !seen.has(s.key))];
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own')));
|
||||
|
||||
@@ -56,7 +56,7 @@ interface Entry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower bound of a `trek` range like ">=3.2.0 <4.0.0" -> "3.2.0" (matches the server).
|
||||
* 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
|
||||
@@ -91,7 +91,7 @@ export function buildEntry(opts: {
|
||||
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": ">=3.2.0 <4.0.0")');
|
||||
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);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`;
|
||||
export const PATH_COMMANDS = ['create', 'dev', 'status', 'publish'] as const;
|
||||
|
||||
/** Real, supported, and not what a newcomer needs to see first. */
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release'] as const;
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release', 'unrelease'] as const;
|
||||
|
||||
export interface CommandHelp {
|
||||
/** One line, for the top-level list. */
|
||||
@@ -85,7 +85,7 @@ you like.`,
|
||||
|
||||
publish: {
|
||||
summary: 'release + open the registry PR',
|
||||
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign]',
|
||||
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign] [--keep-release]',
|
||||
body: `The whole release, in order:
|
||||
|
||||
1. check every registry gate that can be checked locally
|
||||
@@ -98,6 +98,12 @@ Step 1 comes first for a reason: a GitHub release is effectively immutable, beca
|
||||
pins its sha256. If a check fails, NOTHING is packed, tagged, pushed or released — so you can fix
|
||||
it and re-run against the same version.
|
||||
|
||||
And when a LATER step fails, publish rolls back exactly what the run created — the release, the
|
||||
pushed tag, the local tag — so the same tag is free to re-run against once you have fixed the
|
||||
problem. Pass --keep-release to leave them in place instead (the error then prints the manual
|
||||
cleanup). Anything that existed before the run is never touched. A leftover tag from an earlier
|
||||
failed run is recognised (no release for it, not on HEAD) and moved to HEAD rather than reused.
|
||||
|
||||
In a terminal it OFFERS TO SIGN, and creates a key for you if you have none. A signature proves the
|
||||
artifact came from you, not just that its bytes match what the registry saw. You can add signing
|
||||
later — publishing unsigned now and signing at v1.4.0 breaks nobody — but you can never take it
|
||||
@@ -217,6 +223,19 @@ hand, or to re-check a release you have already cut.
|
||||
body: `Packs, cuts the GitHub release, and prints the registry entry — but does NOT open the registry PR.
|
||||
For when you want to release now and submit later. \`publish\` is the whole thing.`,
|
||||
},
|
||||
unrelease: {
|
||||
summary: 'delete a stranded tag + release (never a published one)',
|
||||
usage: 'trek-plugin unrelease <vX.Y.Z> [dir] --repo <owner/name> [--registry owner/name] [--yes]',
|
||||
body: `Undoes a release that never made it into the registry: deletes the GitHub release, the remote
|
||||
tag, and the local tag — each reported, each skipped when already gone. For when a publish
|
||||
failed halfway (though \`publish\` now rolls its own failures back), or you changed your mind
|
||||
before the registry PR merged.
|
||||
|
||||
The one hard rule: a version that IS published in the registry is immutable — the registry pins
|
||||
the artifact's sha256, so deleting its release breaks install/update for everyone who has it.
|
||||
unrelease checks the published index first and refuses those outright, with no override. If the
|
||||
registry cannot be reached to check, it refuses unless you pass --yes.`,
|
||||
},
|
||||
};
|
||||
|
||||
export function topLevelHelp(version: string): string {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ADVANCED_MENU: MenuItem[] = [
|
||||
{ value: 'preflight', label: 'Preflight', hint: 'The registry checks that need the release to exist' },
|
||||
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
|
||||
{ value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' },
|
||||
{ value: 'unrelease', label: 'Unrelease', hint: 'Delete a stranded tag + release (never a published one)' },
|
||||
{ value: 'back', label: '← Back', hint: '' },
|
||||
];
|
||||
|
||||
|
||||
@@ -31,8 +31,12 @@ import { plainLog, type LogSink } from './ui.js';
|
||||
function git(dir: string, args: string[], quiet = true): string {
|
||||
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim();
|
||||
}
|
||||
function tagExists(dir: string, tag: string): boolean {
|
||||
try { git(dir, ['rev-parse', `${tag}^{commit}`]); return true; } catch { return false; }
|
||||
/** The commit a local tag points at, or null when the tag does not exist. */
|
||||
function tagSha(dir: string, tag: string): string | null {
|
||||
try { return git(dir, ['rev-parse', `${tag}^{commit}`]); } catch { return null; }
|
||||
}
|
||||
function remoteTagExists(dir: string, tag: string): boolean {
|
||||
try { return git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { return false; }
|
||||
}
|
||||
function releaseExists(repo: string, tag: string): boolean {
|
||||
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); return true; } catch { return false; }
|
||||
@@ -45,6 +49,8 @@ export async function publishPlugin(opts: {
|
||||
force?: boolean;
|
||||
/** Skip the local gates. An escape hatch for a re-run, never a good idea on a first publish. */
|
||||
skipChecks?: boolean;
|
||||
/** On failure, leave the tag/release this run created in place instead of rolling them back. */
|
||||
keepRelease?: boolean;
|
||||
/**
|
||||
* A signing state the CLI already looked up (it does, to know whether to offer signing). Passed
|
||||
* in so a single publish does not hit the registry twice for the same answer.
|
||||
@@ -93,14 +99,36 @@ export async function publishPlugin(opts: {
|
||||
|
||||
// 3. Tag (if needed) + push + GitHub release with the artifact attached
|
||||
step(3, `Tagging ${opts.tag} + creating the GitHub release…`);
|
||||
if (!tagExists(dir, opts.tag)) git(dir, ['tag', opts.tag]);
|
||||
try { git(dir, ['push', 'origin', opts.tag]); } catch {
|
||||
const head = git(dir, ['rev-parse', 'HEAD']);
|
||||
const existingTagSha = tagSha(dir, opts.tag);
|
||||
const relExisted = releaseExists(opts.repo, opts.tag);
|
||||
let createdLocalTag = false;
|
||||
let movedTag = false;
|
||||
if (existingTagSha === null) {
|
||||
git(dir, ['tag', opts.tag]);
|
||||
createdLocalTag = true;
|
||||
} else if (existingTagSha !== head) {
|
||||
// A tag that doesn't point at HEAD and has no release is debris from a failed run — reusing
|
||||
// it silently would release the UNFIXED commit. Move it. One that HAS a release is immutable.
|
||||
if (relExisted && !opts.force) {
|
||||
throw new Error(
|
||||
`tag ${opts.tag} points at ${existingTagSha.slice(0, 7)}, not HEAD (${head.slice(0, 7)}) — and its release already exists on ${opts.repo}.\n` +
|
||||
`A released artifact is immutable (the registry pins its sha256). Cut a new version,\n` +
|
||||
`or pass --force if this release was never merged into the registry.`,
|
||||
);
|
||||
}
|
||||
log(` ! tag ${opts.tag} pointed at ${existingTagSha.slice(0, 7)} (debris from an earlier run) — moving it to HEAD`);
|
||||
git(dir, ['tag', '-f', opts.tag]);
|
||||
movedTag = true;
|
||||
}
|
||||
const remoteTagExisted = remoteTagExists(dir, opts.tag);
|
||||
try { git(dir, ['push', ...(movedTag ? ['--force'] : []), 'origin', opts.tag]); } catch {
|
||||
throw new Error(`could not push tag ${opts.tag} — is "origin" your plugin's GitHub repo and are you authenticated? (git push origin ${opts.tag})`);
|
||||
}
|
||||
// A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes of a
|
||||
// release that is already in the registry breaks the checksum for everyone who has that version
|
||||
// — they can no longer install or update it. Refuse by default.
|
||||
if (releaseExists(opts.repo, opts.tag)) {
|
||||
if (relExisted) {
|
||||
if (!opts.force) {
|
||||
throw new Error(
|
||||
`release ${opts.tag} already exists on ${opts.repo}.\n` +
|
||||
@@ -115,31 +143,65 @@ export async function publishPlugin(opts: {
|
||||
}
|
||||
log(` ✓ release ${opts.tag} on ${opts.repo}`);
|
||||
|
||||
// 4. Build the entry, then run the gates that need the tag and the release to exist
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
|
||||
if (opts.skipPreflight) {
|
||||
log('[4/5] Preflight skipped (--no-preflight).');
|
||||
} else {
|
||||
step(4, 'Preflight — the gates that need the release to exist…');
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry });
|
||||
for (const f of rep.failures) log(' ✗ ' + f);
|
||||
if (!rep.ok) {
|
||||
// What THIS run created — the exact scope of a rollback. Anything that existed before the run
|
||||
// (a merged release, a pre-pushed tag) is deliberately out of bounds.
|
||||
const created = { release: !relExisted, remoteTag: !remoteTagExisted, localTag: createdLocalTag || movedTag };
|
||||
const manualCleanup =
|
||||
` gh release delete ${opts.tag} --repo ${opts.repo} --yes\n` +
|
||||
` git push origin :refs/tags/${opts.tag}\n` +
|
||||
` git tag -d ${opts.tag}`;
|
||||
const rollback = () => {
|
||||
if (created.release) {
|
||||
try { execFileSync('gh', ['release', 'delete', opts.tag, '--repo', opts.repo, '--yes'], { stdio: 'pipe' }); log(` ↩ deleted release ${opts.tag}`); }
|
||||
catch { log(` ! could not delete release ${opts.tag} — run: gh release delete ${opts.tag} --repo ${opts.repo} --yes`); }
|
||||
}
|
||||
if (created.remoteTag) {
|
||||
try { git(dir, ['push', 'origin', `:refs/tags/${opts.tag}`]); log(` ↩ deleted remote tag ${opts.tag}`); }
|
||||
catch { log(` ! could not delete remote tag ${opts.tag} — run: git push origin :refs/tags/${opts.tag}`); }
|
||||
}
|
||||
if (created.localTag) {
|
||||
try { git(dir, ['tag', '-d', opts.tag]); log(` ↩ deleted local tag ${opts.tag}`); }
|
||||
catch { log(` ! could not delete local tag ${opts.tag} — run: git tag -d ${opts.tag}`); }
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 4. Build the entry, then run the gates that need the tag and the release to exist
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
|
||||
if (opts.skipPreflight) {
|
||||
log('[4/5] Preflight skipped (--no-preflight).');
|
||||
} else {
|
||||
step(4, 'Preflight — the gates that need the release to exist…');
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry });
|
||||
for (const f of rep.failures) log(' ✗ ' + f);
|
||||
if (!rep.ok) {
|
||||
throw new Error(
|
||||
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
|
||||
`Did you push your code to ${opts.repo} before publishing?`,
|
||||
);
|
||||
}
|
||||
log(` ✓ all ${rep.passed.length} checks passed`);
|
||||
}
|
||||
|
||||
// 5. Open the registry PR
|
||||
step(5, 'Opening the registry PR…');
|
||||
const { prUrl } = await submitEntry(entry, { registry: opts.registry, draft: opts.draft, signKeyPath: opts.signKeyPath });
|
||||
log(' ✓ done');
|
||||
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
|
||||
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
|
||||
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
|
||||
log(` artifact kept at ${zip}`);
|
||||
return { prUrl };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (opts.keepRelease) {
|
||||
throw new Error(
|
||||
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
|
||||
`The release is cut, so fix these, commit, and re-run against a NEW tag (or --force if this one was never merged).\n` +
|
||||
`Did you push your code to ${opts.repo} before publishing?`,
|
||||
`${msg}\n\nThe tag and release were left in place (--keep-release). To clean up by hand:\n${manualCleanup}`,
|
||||
);
|
||||
}
|
||||
log(` ✓ all ${rep.passed.length} checks passed`);
|
||||
rollback();
|
||||
throw new Error(
|
||||
`${msg}\n\nEverything this run created was rolled back — the tag ${opts.tag} is free again. Fix the problem and re-run.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Open the registry PR
|
||||
step(5, 'Opening the registry PR…');
|
||||
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
|
||||
log(' ✓ done');
|
||||
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
|
||||
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
|
||||
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
|
||||
log(` artifact kept at ${zip}`);
|
||||
return { prUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Retro-signing: the missing half of "you can start signing late".
|
||||
*
|
||||
* Server-side, adding a key at v1.4.0 breaks nobody — TREK pins the key only when a signed
|
||||
* version installs. But the REGISTRY refuses a mixed entry (key present, older versions
|
||||
* unsigned), and for good reason: once an instance pins your key, it refuses the unsigned
|
||||
* older versions, and "install latest compatible" can legitimately resolve an older host to
|
||||
* one of them. So every version must be signed the moment a key appears — which used to mean
|
||||
* downloading each old artifact and signing it by hand.
|
||||
*
|
||||
* This does that tedious part: for each unsigned version it downloads the pinned artifact,
|
||||
* verifies the bytes still hash to the entry's pinned sha256 (a mismatch means the release
|
||||
* asset was changed — a far bigger problem than signing), and signs those exact bytes.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { loadPrivateKey, publicKeyBase64, signArtifact } from './sign.js';
|
||||
|
||||
export interface RetroSignableEntry {
|
||||
id?: string;
|
||||
authorPublicKey?: string;
|
||||
versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }>;
|
||||
}
|
||||
|
||||
/** True when the entry carries a key but not a signature on every version. */
|
||||
export function needsRetroSign(entry: RetroSignableEntry): boolean {
|
||||
return !!entry.authorPublicKey && entry.versions.some((v) => !v.signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every unsigned version in place with the key at `keyPath`. Returns the versions it
|
||||
* signed. Throws — without half-signing the entry — when the key doesn't match the entry's
|
||||
* `authorPublicKey`, or when any artifact can't be fetched or no longer matches its pin.
|
||||
*/
|
||||
export async function retroSignVersions(
|
||||
entry: RetroSignableEntry,
|
||||
keyPath: string,
|
||||
log: (line: string) => void = () => {},
|
||||
): Promise<string[]> {
|
||||
if (!needsRetroSign(entry)) return [];
|
||||
|
||||
const key = loadPrivateKey(keyPath);
|
||||
if (publicKeyBase64(key) !== entry.authorPublicKey) {
|
||||
throw new Error(
|
||||
'this signing key differs from the entry\'s authorPublicKey — retro-signing older versions with it would produce signatures TREK rejects. Use the original key.',
|
||||
);
|
||||
}
|
||||
|
||||
const unsigned = entry.versions.filter((v) => !v.signature);
|
||||
const signatures = new Map<string, string>();
|
||||
const failures: string[] = [];
|
||||
for (const v of unsigned) {
|
||||
try {
|
||||
const r = await fetch(v.downloadUrl, { headers: { 'User-Agent': 'trek-plugin' } });
|
||||
if (!r.ok) { failures.push(`v${v.version}: HTTP ${r.status} fetching ${v.downloadUrl}`); continue; }
|
||||
const bytes = Buffer.from(await r.arrayBuffer());
|
||||
const hash = createHash('sha256').update(bytes).digest('hex');
|
||||
if (hash !== v.sha256) {
|
||||
failures.push(`v${v.version}: the release asset no longer hashes to its pinned sha256 (got ${hash.slice(0, 12)}…, pinned ${v.sha256.slice(0, 12)}…) — the artifact was changed after publishing, which is a bigger problem than signing`);
|
||||
continue;
|
||||
}
|
||||
signatures.set(v.version, signArtifact(bytes, key));
|
||||
} catch (e) {
|
||||
failures.push(`v${v.version}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
throw new Error(
|
||||
`could not retro-sign ${failures.length} older version(s) — the registry requires every version signed once a key is present:\n` +
|
||||
failures.map((f) => ` ✗ ${f}`).join('\n') +
|
||||
'\n\nNothing was signed. Fix the artifacts above (or remove those versions from the entry) and re-run.',
|
||||
);
|
||||
}
|
||||
|
||||
// All-or-nothing: only mutate the entry once every artifact verified and signed.
|
||||
for (const v of unsigned) {
|
||||
v.signature = signatures.get(v.version)!;
|
||||
log(` ✓ retro-signed v${v.version}`);
|
||||
}
|
||||
return unsigned.map((v) => v.version);
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readJsonFile } from './json.js';
|
||||
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
|
||||
|
||||
const DEFAULT_REGISTRY = 'liketrek/TREK-Plugins';
|
||||
|
||||
interface EntryLike {
|
||||
id: string; name?: string; authorPublicKey?: string;
|
||||
versions: { version: string }[];
|
||||
versions: { version: string; downloadUrl: string; sha256: string; signature?: string }[];
|
||||
}
|
||||
|
||||
function git(cwd: string, ...a: string[]): string {
|
||||
@@ -42,7 +43,7 @@ function mergeOnto(existing: EntryLike, fresh: EntryLike): EntryLike {
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean } = {}): { prUrl: string } {
|
||||
export async function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean; signKeyPath?: string } = {}): Promise<{ prUrl: string }> {
|
||||
const registry = opts.registry || DEFAULT_REGISTRY;
|
||||
const name = registry.split('/')[1];
|
||||
const login = gh('api', 'user', '--jq', '.login');
|
||||
@@ -51,6 +52,17 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
|
||||
// Ensure the fork exists (idempotent — prints "already exists" if it does).
|
||||
try { gh('repo', 'fork', registry, '--clone=false'); } catch { /* already forked */ }
|
||||
|
||||
// Fast-forward the fork from the registry. The PR branch is based on upstream/main either
|
||||
// way, but a fork left far behind has broken pushes and PRs in the wild — and costs nothing
|
||||
// to keep current. Best-effort: a diverged fork can't be fast-forwarded, and that alone
|
||||
// must not sink the submit.
|
||||
try { gh('repo', 'sync', `${login}/${name}`, '--source', registry); } catch {
|
||||
console.error(
|
||||
`! could not fast-forward your fork ${login}/${name} (diverged?) — continuing, the PR branch is based on the registry's main.\n` +
|
||||
` If the push or PR fails, reset the fork with: gh repo sync ${login}/${name} --force`,
|
||||
);
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-submit-'));
|
||||
try {
|
||||
// Clone the fork (pushable via gh auth), then base a branch on the CURRENT upstream main.
|
||||
@@ -77,6 +89,19 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
|
||||
toWrite = mergeOnto(existing, entry);
|
||||
action = 'update';
|
||||
}
|
||||
// First signed update onto an unsigned history: the registry refuses a mixed entry (key
|
||||
// present, older versions unsigned) — see retro-sign.ts for why. Sign the older versions
|
||||
// with the same key, or say exactly what is missing.
|
||||
if (needsRetroSign(toWrite)) {
|
||||
if (!opts.signKeyPath) {
|
||||
throw new Error(
|
||||
`this update adds your signing key, but ${toWrite.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
|
||||
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
|
||||
);
|
||||
}
|
||||
console.error(' older versions are unsigned — signing them with your key so the registry accepts the update');
|
||||
await retroSignVersions(toWrite, opts.signKeyPath, (line) => console.error(line));
|
||||
}
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, JSON.stringify(toWrite, null, 2) + '\n');
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import { runShot } from './shot.js';
|
||||
import { preflight } from './preflight.js';
|
||||
import { submitEntry } from './submit.js';
|
||||
import { publishPlugin } from './publish.js';
|
||||
import { unrelease } from './unrelease.js';
|
||||
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
|
||||
import { loadContext } from './checks/context.js';
|
||||
import { runOffline } from './checks/index.js';
|
||||
import { renderPlain } from './checks/report.js';
|
||||
@@ -81,7 +83,8 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
||||
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry'],
|
||||
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep'],
|
||||
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force', 'keep-release'],
|
||||
unrelease: ['repo', 'tag', 'registry', 'yes'],
|
||||
};
|
||||
|
||||
function assertKnownFlags(command: string, f: Flags): void {
|
||||
@@ -107,6 +110,26 @@ function signKey(f: Flags): string | undefined {
|
||||
return f.sign === 'true' ? (f.key || defaultKeyPath()) : f.sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* First signed update onto an unsigned history (`--merge` + `--sign`): the registry requires
|
||||
* every version signed once a key is present, so sign the older versions too — or say exactly
|
||||
* what is missing. See retro-sign.ts.
|
||||
*/
|
||||
async function maybeRetroSign(
|
||||
entry: { authorPublicKey?: string; versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }> },
|
||||
keyPath: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!needsRetroSign(entry)) return;
|
||||
if (!keyPath) {
|
||||
fail(
|
||||
`this entry carries your signing key, but ${entry.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
|
||||
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
|
||||
);
|
||||
}
|
||||
console.error('older versions are unsigned — signing them with your key so the registry accepts the update');
|
||||
await retroSignVersions(entry, keyPath!, (line) => console.error(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repo + tag every publishing command needs. Interactive: prompt for
|
||||
* whatever is missing. Non-interactive: reproduce the command's exact error so
|
||||
@@ -322,6 +345,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
commit: f.commit, asset: f.asset, mergePath: f.merge,
|
||||
signKeyPath: signKey(f), now: new Date().toISOString(),
|
||||
});
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
const json = JSON.stringify(entry, null, 2) + '\n';
|
||||
if (f.out) {
|
||||
fs.writeFileSync(f.out, json);
|
||||
@@ -384,6 +408,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
dir, repo, tag,
|
||||
signKeyPath, signing, registry: f.registry, draft: !!f.draft,
|
||||
notes: f.notes, skipPreflight: !!f['no-preflight'], skipChecks: !!f['no-checks'], force: !!f.force,
|
||||
keepRelease: !!f['keep-release'],
|
||||
now: new Date().toISOString(),
|
||||
log: tui ? clackLogSink : undefined,
|
||||
});
|
||||
@@ -400,12 +425,12 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true });
|
||||
if (!ok) { outro('Cancelled — no PR opened.'); return; }
|
||||
const s = spinner(); s.start('Opening the registry PR');
|
||||
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
s.stop('Registry PR opened');
|
||||
console.log(prUrl);
|
||||
} else {
|
||||
console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`);
|
||||
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
console.log(prUrl);
|
||||
}
|
||||
} else if (command === 'release') {
|
||||
@@ -421,6 +446,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' });
|
||||
s.stop(`Released ${tag} on ${repo}`);
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`);
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
} else {
|
||||
@@ -429,9 +455,28 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
console.error(`Creating GitHub release ${tag} on ${repo}…`);
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' });
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n');
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
}
|
||||
} else if (command === 'unrelease') {
|
||||
if (positional[0]) f.tag = positional[0];
|
||||
const { repo, tag } = await ensureRepoTag(f, 'unrelease needs a tag and --repo <owner/name>: trek-plugin unrelease vX.Y.Z --repo <owner/name>');
|
||||
const dir = positional[1] || '.';
|
||||
let yes = !!f.yes;
|
||||
if (tui && !yes) {
|
||||
const ok = await promptConfirm({
|
||||
message: `Delete the GitHub release, remote tag and local tag ${tag} on ${repo}?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!ok) { outro('Cancelled — nothing was deleted.'); return; }
|
||||
yes = true; // the human just consented — that covers the registry-unreachable guard too
|
||||
}
|
||||
const { deleted } = await unrelease({ dir, tag, repo, registry: f.registry, yes, log: tui ? clackLogSink : undefined });
|
||||
const summary = deleted.length
|
||||
? `Deleted: ${deleted.join(', ')} — ${tag} is free to publish again.`
|
||||
: `Nothing to delete — no release or tags found for ${tag}.`;
|
||||
if (tui) logSuccess(summary); else console.error(summary);
|
||||
} else {
|
||||
console.error(`unknown command: ${command}\n${USAGE}`);
|
||||
process.exit(2);
|
||||
|
||||
@@ -268,7 +268,7 @@ export const PERMISSION_CATALOG: { value: string; label: string; hint: string; f
|
||||
);
|
||||
|
||||
/** Every grantable permission id. Derived — never hand-maintained. */
|
||||
export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
|
||||
export const PICKER_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
|
||||
|
||||
// ── reporter seam (for commands that log their own progress, e.g. publish) ────
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* trek-plugin unrelease <tag> — undo a release that never made it into the registry.
|
||||
*
|
||||
* A failed publish (or a change of heart before the PR merges) leaves up to three things
|
||||
* behind: the GitHub release, the remote tag, and the local tag. Cleaning them up by hand
|
||||
* is three commands in the right order; this is that, with one hard safety rail:
|
||||
*
|
||||
* A version that IS published in the registry is immutable — the registry pins the
|
||||
* artifact's sha256, so deleting or re-cutting its release breaks install/update for
|
||||
* everyone who already has it. unrelease refuses those outright, with no override.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readJsonFile } from './json.js';
|
||||
import { DEFAULT_REGISTRY } from './checks/network.js';
|
||||
import { plainLog, type LogSink } from './ui.js';
|
||||
|
||||
function git(dir: string, args: string[]): string {
|
||||
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
interface PublishedIndex { plugins?: Array<{ id?: string; versions?: Array<{ version?: string }> }> }
|
||||
|
||||
/**
|
||||
* The published registry index, or null when it cannot be read (offline, mirror down).
|
||||
* Null is the dangerous answer — the caller must then have explicit consent (--yes).
|
||||
*/
|
||||
async function publishedIndex(registry: string): Promise<PublishedIndex | null> {
|
||||
try {
|
||||
const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/dist/index.json`, {
|
||||
headers: { 'User-Agent': 'trek-plugin' },
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as PublishedIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unrelease(opts: {
|
||||
dir: string; tag: string; repo: string; registry?: string;
|
||||
/** Consent to delete when the registry index cannot be verified. */
|
||||
yes?: boolean;
|
||||
log?: LogSink;
|
||||
}): Promise<{ deleted: string[] }> {
|
||||
const log = opts.log ?? plainLog;
|
||||
const dir = path.resolve(opts.dir);
|
||||
const { tag, repo } = opts;
|
||||
const registry = opts.registry || DEFAULT_REGISTRY;
|
||||
const manifest = readJsonFile<{ id?: unknown }>(path.join(dir, 'trek-plugin.json'));
|
||||
const id = String(manifest.id ?? '');
|
||||
const version = tag.replace(/^v/, '');
|
||||
|
||||
const index = await publishedIndex(registry);
|
||||
if (index === null) {
|
||||
if (!opts.yes) {
|
||||
throw new Error(
|
||||
`could not read the registry index (${registry}) to verify ${id} ${version} is not published.\n` +
|
||||
`Deleting a PUBLISHED version's release breaks its sha256 pin for everyone who installed it.\n` +
|
||||
`If you are sure this version never merged into the registry, re-run with --yes.`,
|
||||
);
|
||||
}
|
||||
log(` ! registry unreachable — proceeding on --yes`);
|
||||
} else {
|
||||
const mine = index.plugins?.find((p) => p.id === id);
|
||||
if (mine?.versions?.some((v) => v.version === version)) {
|
||||
throw new Error(
|
||||
`${id} ${version} is PUBLISHED in the registry (${registry}) — its release is immutable.\n` +
|
||||
`The registry pins this artifact's sha256; deleting the release would break install/update\n` +
|
||||
`for everyone who has it. Ship a new version instead. (There is deliberately no override.)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in blast-radius order: release, remote tag, local tag. Each is best-effort and
|
||||
// reported, so a partial previous cleanup doesn't stop the rest.
|
||||
const deleted: string[] = [];
|
||||
|
||||
let hasRelease = false;
|
||||
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); hasRelease = true; } catch { /* absent */ }
|
||||
if (hasRelease) {
|
||||
execFileSync('gh', ['release', 'delete', tag, '--repo', repo, '--yes'], { stdio: 'pipe' });
|
||||
deleted.push('release');
|
||||
log(` ✓ deleted release ${tag} on ${repo}`);
|
||||
} else {
|
||||
log(` - no release ${tag} on ${repo}`);
|
||||
}
|
||||
|
||||
let hasRemoteTag = false;
|
||||
try { hasRemoteTag = git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { /* no origin — nothing to delete */ }
|
||||
if (hasRemoteTag) {
|
||||
git(dir, ['push', 'origin', `:refs/tags/${tag}`]);
|
||||
deleted.push('remote tag');
|
||||
log(` ✓ deleted remote tag ${tag}`);
|
||||
} else {
|
||||
log(` - no tag ${tag} on origin`);
|
||||
}
|
||||
|
||||
let hasLocalTag = false;
|
||||
try { git(dir, ['rev-parse', `${tag}^{commit}`]); hasLocalTag = true; } catch { /* absent */ }
|
||||
if (hasLocalTag) {
|
||||
git(dir, ['tag', '-d', tag]);
|
||||
deleted.push('local tag');
|
||||
log(` ✓ deleted local tag ${tag}`);
|
||||
} else {
|
||||
log(` - no local tag ${tag}`);
|
||||
}
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
Reference in New Issue
Block a user