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:
jubnl
2026-08-24 23:48:36 +02:00
committed by GitHub
parent 4bac447ac8
commit d110b3bb6d
48 changed files with 1895 additions and 169 deletions
+1 -1
View File
@@ -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);
},
+34 -5
View File
@@ -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')));
+2 -2
View File
@@ -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);
+21 -2
View File
@@ -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 {
+1
View File
@@ -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: '' },
];
+90 -28
View File
@@ -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 };
}
+81
View File
@@ -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);
}
+27 -2
View File
@@ -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');
+48 -3
View File
@@ -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);
+1 -1
View File
@@ -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) ────
+110
View File
@@ -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 };
}