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
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this package is
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives inside the TREK monorepo but is **not one of the root npm workspaces**: it has its own `package-lock.json`, is versioned and published to npm independently, and must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`, not the repo root. The repo root's CLAUDE.md covers the main app; its conventions (target `dev` branch, conventional commits) still apply.
It ships three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`) and `create-trek-plugin` (`src/cli/create.ts`).
## Commands
```bash
npm install
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
npm run typecheck # tsc --noEmit
npm test # vitest run
npx vitest run test/sdk.test.ts # single test file
npx vitest run -t "name of the test" # by test-name pattern
node scripts/gen-lucide-icon-names.mjs # regenerate src/lucide-icon-names.ts (uses repo-root lucide-react)
```
Node >= 18. The package is `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` scope marker there — without it Node parses the CJS output as ESM).
## The load-bearing principle: mirrored host code + parity tests
The SDK's core promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
Because the package ships standalone, it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original source directly. These tests skip outside the monorepo, but in this checkout they run — keep the pairs in sync when touching either side:
| SDK copy | Source of truth | Parity test |
|---|---|---|
| `src/permissions.ts` (`HOOK_PERMISSION`) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` | `test/permissions-parity.test.ts` |
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
| `src/manifest.ts` (validation rules, `KNOWN_ADDONS`) | server plugin loader / `server/src/addons.ts` | — kept in sync by hand |
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; set `TREK_PLUGINS_REPO` to point at a checkout) |
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format is frozen against the reader |
`src/permissions.ts` also documents a subtlety worth preserving: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud (an ungranted `run(def).hook(...)` throws).
**Direction: single-source the contract.** The protocol core — permissions, RPC methods, hooks, the event catalog — is already single-sourced: `src/generated/host-facts.ts` is **machine-written** by `server/scripts/gen-plugin-facts.ts` from `server/src/nest/plugins/protocol/envelope.ts` (never hand-edit it; the server's `check:plugin-facts` CI gate catches drift). The remaining hand-kept copies (egress-policy, manifest rules, registry gates, the ZIP writer) are the residual risk, and the `skipIf(!inMonorepo)` escape hatch means the published SDK's own CI can green-light drift in those — so keep moving copies into the generated/parity-tested set, never away from it. Until then: changing an egress rule, manifest rule, or registry gate means touching **every** copy in one change; parity gates fail closed, never leniently; and version/compat checks fail **closed** on unknown host versions rather than assuming compatibility.
## Architecture
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's isolated plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (e.g. Playwright for `shot` is intentionally the author's devDependency, and the ZIP writer is in-tree instead of a dependency).
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` + driver enforcing the same permission model against configured fixtures.
- **`src/cli/`** — one file per command, dispatched by `trek-plugin.ts`; `menu.ts` is the no-command guided menu. Interactive TTY sessions get prompts; non-TTY (CI, pipes) stays flag-driven — machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so `packPluginDir` can validate before zipping without going async) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`). Printing lives in `report.ts`: `status` renders the checklist and never exits non-zero; `validate` is the same checks with an exit code.
- **`src/ui/kit.ts`** — the design kit (`TREK_UI_CSS`, `TREK_THEME_JS`) as plain strings, inlined into a plugin's `client/index.html` where the `<!-- trek:ui -->` marker sits (`dev`/`pack` expand it; the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
- **`src/lucide-icon-names.ts`** — generated snapshot (see command above); an unknown icon is a validate **warning**, never an error.
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin (README with per-permission lines, ≥400 prose chars, screenshot); the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
- **`examples/`** — complete example plugins (`trip-doctor`, `koffi`).
## Behavioral contracts to not break
- `status` never exits non-zero; `validate` is the enforcing form.
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged/released (releases are immutable in practice).
- Signed → unsigned is refused forever; unsigned → signed is always allowed.
- Update notices (update-notifier) print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
+75 -2
View File
@@ -140,6 +140,54 @@ const { ctx, broadcasts } = createMockHost({
// degrades gracefully when a permission is missing.
```
## Runtime limits
The host enforces these limits on every ctx call and hook invocation. They are generous
for a legitimate plugin and only bite a runaway or abusive one — build against them so
`dev`/`mock-host` behavior matches production.
| Area | Limit |
|---|---|
| `ctx.ai` | 200 calls/day per plugin (UTC midnight rollover); past it throws `"daily AI budget exhausted (resets at UTC midnight)"` |
| `ctx.notify` | 100 calls/day per plugin (UTC midnight rollover); past it throws `"daily notification budget exhausted (resets at UTC midnight)"` |
| RPC (every `ctx.*` call) | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls` |
| `ctx.db` (your own sqlite) | 256 MB per plugin |
| `ctx.meta` | 64 KB per value, 256 chars per key, 100 keys per (plugin, entity) |
| Plugin process | 300 MB RSS ceiling; auto-disabled after 5 crashes in 5 minutes |
| Event redelivery buffer | 200 events held per plugin, dropped unreplayed after 15 minutes |
Hook contribution caps (declarative content the host renders — a slow/failing call is
skipped, never fatal):
| Hook | Cap |
|---|---|
| `pdfSectionProvider` | ≤5 sections per provider |
| `atlasLayerProvider` | ≤3 layers per provider |
| `mapMarkerProvider` | ≤200 markers per provider |
| `warningProvider` | ≤20 warnings per provider, each message ≤300 chars |
| `placeDetailProvider` | ≤12 items per provider |
| `photoProvider` | ≤60 photos per page |
| `calendarSource` | ≤500 events per source per request |
| `tableContributor` | ≤20 columns / ≤10 actions per entity |
| `tripCardProvider` | ≤4 badges per trip, ≤240 total per provider |
## OAuth broker
`ctx.oauth.getAccessToken()` is host-brokered outbound OAuth: the plugin never becomes a
direct OAuth client. The host runs the whole flow (authorize → callback → token exchange
→ refresh) with PKCE and a 10-minute state TTL, and holds the refresh token + client
secret — a plugin only ever sees a short-lived access token for the ACTING USER, or
`null` when they haven't connected (or in a userless context).
Provider config is the plugin's admin-owned INSTANCE settings — declare `scope:'instance'`
manifest fields named `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
`oauth_client_id`, `oauth_client_secret`, and the admin fills them in from Settings →
Plugins → Connect. Both endpoint URLs must be `https` and may not point at loopback,
`.local`/`.internal` names, or private/metadata addresses — the host refuses to start a
flow against them. The host exposes
`/api/plugin-oauth/:id/status|connect|callback|disconnect` to drive the connect UI;
plugin code itself only ever calls `getAccessToken()`.
## Capture the screenshot
The registry requires a screenshot that resolves to a real image, and the store card
@@ -180,7 +228,28 @@ Needs `git` and an authenticated `gh`. It prints the PR URL at the end.
**Updating** a listed plugin: bump `version` in the manifest, commit, and run
`publish` again with the new tag — it detects the existing entry and prepends the
new version, newest-first.
new version, newest-first. Your TREK-Plugins fork is fast-forwarded automatically
before the PR branch is cut, so a fork left behind doesn't break the submit (a
*diverged* fork gets a warning and the `gh repo sync --force` command to fix it).
### When a publish fails
A failure **after** the release is cut (a preflight or PR problem) rolls back
exactly what that run created — the GitHub release, the pushed tag, the local
tag — so the same tag is free to re-run against once you've fixed the problem.
Nothing that existed before the run is ever touched. Pass `--keep-release` to
keep the release instead; the error then prints the manual cleanup commands.
Two related conveniences:
- A leftover tag from an earlier failed run (it has no release and doesn't point
at `HEAD`) is recognised as debris and **moved to your current commit** instead
of silently releasing the stale one.
- `trek-plugin unrelease <vX.Y.Z> --repo you/repo` deletes a stranded release +
remote tag + local tag in one go. It checks the published registry index first
and **refuses to touch a version that is actually published** — those artifacts
are immutable (the registry pins their sha256), so the only way forward there is
a new version.
Prefer to drive the steps yourself? They still exist individually — `pack`,
`release` (pack → GitHub release → entry), `preflight`, `submit` (opens the PR),
@@ -199,7 +268,10 @@ It is a one-way door you may walk through **late**:
- Unsigned throughout is fine — the sha256 pin is the only guarantee, and TREK accepts it.
- **Unsigned → signed later breaks nobody.** Nothing is pinned until a signed version
installs, so adding a key at v1.4.0 is a real option, not a lost cause.
installs, so adding a key at v1.4.0 is a real option, not a lost cause. The registry
does require every version signed once your key appears in the entry — your first
signed update **retro-signs the older versions for you** (each pinned artifact is
downloaded, verified against its sha256, and signed with the same key).
- **Signed → unsigned is refused forever**, on every instance that already has the plugin.
`publish` refuses that at step 1, before anything is tagged or released.
@@ -242,6 +314,7 @@ bin if you install the package). `trek-plugin help <command>` — or
- `preflight [dir] --repo o/n --tag vX [--entry f] [--all]` — the registry checks that need the network: the tag resolves to the pinned commit, the released artifact downloads and hashes, the id is not bound to another owner, and an update does not drop or rotate a published signing key.
- `submit [dir] --repo o/n --tag vX [--registry o/n] [--draft]` — open the registry PR for you.
- `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, without opening the PR.
- `unrelease <vX.Y.Z> [dir] --repo o/n [--yes]` — delete a stranded release + remote tag + local tag. Refuses a version that is published in the registry (immutable); `--yes` consents when the registry index can't be checked.
## Update notice
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-plugin-sdk",
"version": "1.5.0",
"version": "1.6.0",
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
"type": "module",
"license": "MIT",
+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 };
}
+34 -1
View File
@@ -1,5 +1,5 @@
// GENERATED — do not edit by hand.
// Source: server/src/nest/plugins/protocol/envelope.ts
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
@@ -316,3 +316,36 @@ export const USER_DATA_PERMISSION = 'hook:user-data';
export const EVENTS_PERMISSION = 'events:subscribe';
export const JOBS_PERMISSION = 'jobs:run';
export const HTTP_OUTBOUND_PREFIX = 'http:outbound:';
/**
* Core-event catalog. Delivery names are the WebSocket broadcast names,
* `<family>:<verb>` (e.g. `place:created`). A subscribed plugin receives
* `{event, tripId, entity?, entityId?, snapshot?}`; `snapshot` is delivered only
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
* events carry no snapshot.
*/
export const EVENT_FAMILIES: readonly string[] = [
'accommodation',
'assignment',
'budget',
'day',
'dayNote',
'file',
'packing',
'place',
'reservation',
'trip',
];
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
place: 'db:read:trips',
day: 'db:read:trips',
reservation: 'db:read:trips',
accommodation: 'db:read:trips',
assignment: 'db:read:trips',
trip: 'db:read:trips',
budget: 'db:read:costs',
packing: 'db:read:packing',
dayNote: 'db:read:daynotes',
file: 'db:read:files',
};
+72 -19
View File
@@ -23,6 +23,11 @@ export interface BudgetItem { id: number; trip_id?: number; name?: string; total
export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown }
export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown }
/** Every ctx.* call is rate-limited per plugin at the host RPC dispatch boundary:
* burst 60, sustained 20/s, 16 in-flight. A throttled call is refused (retryable)
* rather than executed, with:
* HOST_ERROR: rate limit exceeded — slow down ctx.* calls
* A legitimate plugin never hits the generous burst. See README § Runtime limits. */
export interface PluginContext {
readonly id: string;
readonly config: Readonly<Record<string, unknown>>;
@@ -32,6 +37,9 @@ export interface PluginContext {
settings: {
get(key: string): Promise<unknown>;
};
/** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a
* path or connection to directly. Quota: 256 MB per plugin (writes past it fail);
* result sets are capped at 100,000 rows. See `tx()` below for the atomic-batch cap. */
db: {
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
@@ -42,10 +50,15 @@ export interface PluginContext {
tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>;
};
trips: {
getById(tripId: number, asUserId?: number): Promise<Trip | null>;
getPlaces(tripId: number, asUserId?: number): Promise<Place[]>;
// `asUserId` is accepted for source compatibility but IGNORED by the host —
// trip reads are always membership-checked against the authenticated user of
// the current invocation (the request's `req.user`), which the plugin cannot
// override. Only reachable from a route handler (a user context); a job has
// no user and its trip reads are refused.
getById(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Trip | null>;
getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Place[]>;
/** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */
getReservations(tripId: number, asUserId?: number): Promise<Reservation[]>;
getReservations(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Reservation[]>;
/** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */
getDays(tripId: number): Promise<Day[]>;
/** The trip's lodging blocks (day_accommodations) with joined place fields. Needs `db:read:trips`. */
@@ -97,7 +110,9 @@ export interface PluginContext {
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<PackingItem>;
/** Delete a packing item. Needs `db:write:packing` + packing_edit. */
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
/** List/create/update/delete packing bags + set members (no privacy). Needs `db:write:packing` + packing_edit. */
// List/create/update/delete packing bags + set members (no privacy). Needs
// `db:write:packing` + packing_edit.
/** Needs 'db:write:packing' (intentional — bags are the write-side structure; packing.list is the read surface). */
listBags(tripId: number): Promise<unknown[]>;
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
@@ -133,13 +148,17 @@ export interface PluginContext {
};
/** Host-mediated notification. The plugin supplies only target + plain text; the host
* owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user',
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`. */
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`.
* Daily budget: 100/day per plugin (UTC midnight rollover); past it `send` throws
* "daily notification budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
notify: {
send(input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<{ sent: boolean }>;
};
/** Host-mediated LLM using the admin/user-configured provider — the plugin never holds a
* key. `complete` returns { text }; `extract` returns { results } for your JSON schema.
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`. */
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`.
* Daily budget: 200/day per plugin (UTC midnight rollover); past it these throw
* "daily AI budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
ai: {
complete(prompt: string, system?: string): Promise<{ text: string }>;
extract(text: string, jsonSchema: object, prompt?: string): Promise<{ results: Record<string, unknown>[] }>;
@@ -147,7 +166,17 @@ export interface PluginContext {
/** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a
* third-party service the host connected on their behalf (Settings → Plugins → Connect).
* Returns null when the user hasn't connected or in a userless context. The host holds
* the refresh token + client secret — you never see them. Needs `oauth:client`. */
* the refresh token + client secret — you never see them. Needs `oauth:client`.
*
* The host runs the whole flow (authorize -> callback -> token exchange -> refresh)
* with PKCE + a 10-minute state TTL. Provider config is the plugin's admin-owned
* INSTANCE settings — declare `scope:'instance'` manifest fields named
* `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
* `oauth_client_id`, `oauth_client_secret` and the admin fills them in. Both
* endpoint URLs must be https and may not point at loopback/`.local`/`.internal`/
* private/metadata addresses. The host exposes
* `/api/plugin-oauth/:id/status|connect|callback|disconnect` for the connect UI —
* plugin code only ever calls `getAccessToken()`. See README § OAuth broker. */
oauth: {
getAccessToken(): Promise<string | null>;
};
@@ -282,7 +311,9 @@ export interface PluginContext {
};
// Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core
// entities without forking the schema. Needs `db:meta`; the entity must belong to
// a trip the current user can access. Values are JSON-serialisable.
// a trip the current user can access. Values are JSON-serialisable. Quotas (per
// plugin + entity): 64 KB serialized JSON per value, 256 chars per key, 100 keys.
// See README § Runtime limits.
meta: {
get(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<unknown>;
set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise<unknown>;
@@ -361,7 +392,8 @@ export interface Photo {
export interface PhotoProvider {
/** Search your photo backend. `ctx` is the last arg so you can reach it via
* ctx.settings/oauth/http. Needs `hook:photo-provider`. Surfaced in the photo picker
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https. */
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https.
* The host caps results at 60 photos per page. */
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
}
@@ -421,18 +453,21 @@ export interface CalendarSource {
// start/end are ISO strings — the host->plugin boundary is JSON, so a Date would
// arrive as a string anyway (kept in lockstep with the runtime SDK copy). `ctx` is the
// last arg. Aggregated for the signed-in user at `GET /api/plugin-calendar`.
// The host caps results at 500 events per source per request.
getEvents(userId: number, start: string, end: string, ctx: PluginContext): Promise<CalendarEvent[]>;
}
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
export interface PlaceDetailProvider {
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`.
* The host caps results at 12 items per provider. */
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
export interface WarningProvider {
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`.
* The host caps results at 20 warnings per provider, each message truncated to 300 chars. */
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
}
@@ -466,7 +501,8 @@ export type TableContribution = TableColumnContribution | TableActionContributio
export interface TableContributor {
/** `view` is one of 'reservations' | 'places' | 'day' | 'costs' | 'packing' | 'files'.
* Runs with the current user bound, on a short timeout; a slow/failing call is
* skipped, never fatal. Needs `hook:table-contributor`. */
* skipped, never fatal. Needs `hook:table-contributor`. The host caps results at
* 20 columns and 10 actions per entity. */
getContributions(view: string, tripId: number, ctx: PluginContext): Promise<TableContribution[]>;
}
@@ -485,7 +521,7 @@ export interface MapMarkerContribution {
export interface MapMarkerProvider {
/** Return markers to overlay on a trip's map. Runs with the current user bound,
* on a short timeout; the host caps the marker count and skips a failing call.
* Needs `hook:map-marker-provider`. */
* Needs `hook:map-marker-provider`. The host caps results at 200 markers per provider. */
getMarkers(tripId: number, ctx: PluginContext): Promise<MapMarkerContribution[]>;
}
@@ -652,7 +688,8 @@ export interface PdfSection {
export interface PdfSectionProvider {
/** Return sections to append to a trip's PDF export. Runs with the current user
* bound, on a short timeout; the host caps counts/lengths and skips a failing
* call. Needs `hook:pdf-section-provider`. */
* call. Needs `hook:pdf-section-provider`. The host caps results at 5 sections
* per provider (see `PdfSection` above for the per-section paragraph/table caps). */
getSections(tripId: number, ctx: PluginContext): Promise<PdfSection[]>;
}
@@ -668,7 +705,8 @@ export interface AtlasLayerProvider {
/** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds
* the current user; the hook takes no target parameter. Runs on a short timeout;
* the host caps the layer/country counts and skips a failing call.
* Needs `hook:atlas-layer-provider`. */
* Needs `hook:atlas-layer-provider`. The host caps results at 3 layers per provider
* (see `AtlasLayer` above for the per-layer country cap). */
getLayers(ctx: PluginContext): Promise<AtlasLayer[]>;
}
@@ -687,7 +725,8 @@ export interface TripCardProvider {
/** Return badges for the dashboard trip cards currently on screen. `tripIds` are
* exactly those cards (each already access-checked for the acting user). Runs with
* the current user bound, on a short timeout; the host caps the badge count and
* skips a failing call. Needs `hook:trip-card-provider`. */
* skips a failing call. Needs `hook:trip-card-provider`. The host caps results at
* 4 badges per trip, 240 total per provider. */
getCards(tripIds: number[], ctx: PluginContext): Promise<TripCardContribution[]>;
}
@@ -703,11 +742,12 @@ export interface JournalEntryProvider {
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job).
* Needs `events:subscribe`. */
export interface PluginEventSubscription {
/** A core event name (e.g. `place:created`, `day:updated`, `file:created`) or `*` for all. */
/** A core event name `<family>:<verb>` (e.g. `place:created`, `day:updated`, `file:created`)
* where `<family>` is a value in EVENT_FAMILIES, or `*` for all. */
on: string;
// `entity` = the event family (e.g. 'reservation'); `entityId` = WHICH entity changed,
// when known. `snapshot` = a whitelisted field view of the changed entity, delivered
// only when the plugin also holds the family's db:read:* grant — never user ids,
// only when the plugin also holds EVENT_SNAPSHOT_GRANT[family] — never user ids,
// private packing items or secrets; delete/reorder/bulk events carry none. Still no
// acting user: a trip read from the handler is refused.
handler(payload: { event: string; tripId: number; entity?: string; entityId?: number; snapshot?: Record<string, unknown> }, ctx: PluginContext): Promise<void> | void;
@@ -778,7 +818,16 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
return def;
}
export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js';
export {
validateManifest,
CHANNEL_EVENTS,
type PluginManifest,
type NormalizedManifest,
type ValidationResult,
type ManifestSettingField,
type ManifestAction,
type ManifestCapabilities,
} from './manifest.js';
export { createMockHost, type MockHostOptions } from './mock-host.js';
// The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry
// point you implement without its grant is never called in production, silently; these
@@ -787,6 +836,10 @@ export {
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints,
} from './permissions.js';
// The core-event catalog: families and the snapshot-delivery permission each family requires.
export {
EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS,
} from './generated/host-facts.js';
/** Scope for host-managed, per-user session state in a sandboxed plugin UI. */
export type PluginSessionStorageScope = 'plugin' | 'trip';
+121 -23
View File
@@ -10,24 +10,69 @@ export interface PluginDependency {
id: string;
version: string;
}
export interface ManifestSettingField {
key: string;
label?: string;
input_type?: string;
placeholder?: string;
hint?: string;
required?: boolean;
secret?: boolean;
scope?: 'instance' | 'user';
options?: Array<string | number | { value: string | number; label?: string }>;
oauth?: { initPath?: string; callbackPath?: string };
}
export interface ManifestAction {
key: string;
label?: string;
hint?: string;
danger?: boolean;
}
export interface ManifestCapabilities {
settingsUi?: boolean;
widget?: { title?: string; defaultSize?: string; slot?: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail' };
tripPage?: { replaces?: string[]; position?: number };
notificationChannel?: { title?: string; events?: string[] };
routeProfiles?: Array<{ id: string; label: string; icon?: string }>;
provides?: string[];
emits?: string[];
}
export interface PluginManifest {
id: string;
name: string;
version: string;
apiVersion: number;
/** The semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
/** The semver RANGE of TREK versions this plugin supports (">=4.0.0 <5.0.0"). */
trek: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
apiVersion?: number;
permissions?: string[];
egress?: string[];
nativeModules?: boolean;
operatorEgress?: boolean;
requiredAddons?: string[];
pluginDependencies?: PluginDependency[];
capabilities?: ManifestCapabilities;
settings?: ManifestSettingField[];
actions?: ManifestAction[];
icon?: string;
author?: string;
description?: string;
homepage?: string;
tags?: string[];
license?: string;
}
export interface NormalizedManifest extends PluginManifest {
apiVersion: number;
permissions: string[];
egress: string[];
nativeModules?: boolean;
nativeModules: false;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
}
export interface ValidationResult {
ok: boolean;
errors: string[];
manifest?: PluginManifest;
manifest?: NormalizedManifest;
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
@@ -123,8 +168,8 @@ export function validateManifest(raw: unknown): ValidationResult {
if (!isSatisfiableRange(m.trek)) {
errors.push(
typeof m.trek === 'string' && m.trek
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=3.2.0 <4.0.0")`
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=4.0.0 <5.0.0")`
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=4.0.0 <5.0.0"',
);
}
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
@@ -209,6 +254,10 @@ export function validateManifest(raw: unknown): ValidationResult {
// Mirrors the server's routeProfiles parsing: the planner's route picker shows these,
// so they must be well-formed and only exist alongside the routeProvider grant.
const routeProfiles = capabilities?.routeProfiles;
// Normalized routeProfiles entries (icon dropped when non-string, like the server's
// optStr()) — populated only when routeProfiles is present and shaped as an array;
// used to build the output below in place of the raw, possibly-dirty input array.
let normalizedRouteProfiles: Array<{ id: string; label: string; icon?: string }> | undefined;
if (routeProfiles !== undefined) {
if (!permissions.includes('hook:route-provider')) {
errors.push('capabilities.routeProfiles requires the "hook:route-provider" permission');
@@ -217,14 +266,21 @@ export function validateManifest(raw: unknown): ValidationResult {
else {
if (routeProfiles.length > 3) errors.push('capabilities.routeProfiles: at most 3 profiles');
const seen = new Set<string>();
normalizedRouteProfiles = [];
for (const v of routeProfiles) {
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown };
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown; icon?: unknown };
const id = typeof p.id === 'string' ? p.id : '';
if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) errors.push('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
else if (seen.has(id)) errors.push(`capabilities.routeProfiles: duplicate id "${id}"`);
else seen.add(id);
const label = typeof p.label === 'string' ? p.label.trim() : '';
if (!label || label.length > 40) errors.push('capabilities.routeProfiles: label is required (max 40 chars)');
// icon is optional; a non-string icon is silently dropped, not rejected — mirrors
// the server's optStr() (server/src/nest/plugins/install/manifest.ts:519-521),
// which returns undefined for a non-string value rather than throwing. Length is
// never rejected either — the server slices to 40 chars at install time.
const icon = typeof p.icon === 'string' ? p.icon : undefined;
normalizedRouteProfiles.push(icon !== undefined ? { id, label, icon } : { id, label });
}
}
}
@@ -243,6 +299,33 @@ export function validateManifest(raw: unknown): ValidationResult {
if (s.scope !== undefined && s.scope !== 'user' && s.scope !== 'instance') {
errors.push(`settings["${key}"].scope must be "user" or "instance"`);
}
if (s.options !== undefined) {
if (!Array.isArray(s.options)) {
errors.push('settings option list must be an array');
} else {
for (const o of s.options) {
if (typeof o === 'string' || typeof o === 'number') continue;
if (o && typeof o === 'object') {
const value = (o as { value?: unknown }).value;
if (value === undefined || value === null || String(value) === '') {
errors.push('settings option must have a non-empty "value"');
}
} else {
errors.push(`invalid settings option ${JSON.stringify(o)} (expected a string or { value, label })`);
}
}
}
}
if (s.oauth !== undefined) {
if (!s.oauth || typeof s.oauth !== 'object' || Array.isArray(s.oauth)) {
errors.push('settings oauth must be an object');
} else {
for (const k of ['initPath', 'callbackPath'] as const) {
const v = (s.oauth as Record<string, unknown>)[k];
if (v !== undefined && typeof v !== 'string') errors.push(`settings oauth.${k} must be a string`);
}
}
}
}
}
// Settings-page action buttons ("Test connection"). Keys share the settings-key rules.
@@ -269,23 +352,38 @@ export function validateManifest(raw: unknown): ValidationResult {
const pluginDependencies = validatePluginDependencies(m.pluginDependencies, typeof m.id === 'string' ? m.id : '', errors);
if (errors.length) return { ok: false, errors };
return {
ok: true,
errors: [],
manifest: {
id: m.id as string,
name: m.name as string,
version: m.version as string,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
trek: m.trek as string,
type: m.type as PluginManifest['type'],
permissions,
egress,
nativeModules: false,
requiredAddons,
pluginDependencies,
},
const manifest: NormalizedManifest = {
id: m.id as string,
name: m.name as string,
version: m.version as string,
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
trek: m.trek as string,
type: m.type as PluginManifest['type'],
permissions,
egress,
nativeModules: false,
requiredAddons,
pluginDependencies,
};
// Carried through verbatim when present — absent stays absent (no undefined keys) — except
// routeProfiles, which is rebuilt from normalizedRouteProfiles so a dropped (non-string)
// icon doesn't leak the raw input value back out.
if (m.capabilities !== undefined) {
const rawCapabilities = m.capabilities as ManifestCapabilities;
manifest.capabilities = normalizedRouteProfiles !== undefined
? { ...rawCapabilities, routeProfiles: normalizedRouteProfiles }
: rawCapabilities;
}
if (m.settings !== undefined) manifest.settings = m.settings as ManifestSettingField[];
if (m.actions !== undefined) manifest.actions = m.actions as ManifestAction[];
if (m.operatorEgress !== undefined) manifest.operatorEgress = m.operatorEgress as boolean;
if (m.icon !== undefined) manifest.icon = m.icon as string;
if (m.author !== undefined) manifest.author = m.author as string;
if (m.description !== undefined) manifest.description = m.description as string;
if (m.homepage !== undefined) manifest.homepage = m.homepage as string;
if (m.tags !== undefined) manifest.tags = m.tags as string[];
if (m.license !== undefined) manifest.license = m.license as string;
return { ok: true, errors: [], manifest };
}
// Export/event names exposed to other plugins (dots allowed for event names).
+48 -1
View File
@@ -107,6 +107,18 @@ export interface MockHostOptions {
aiResults?: Record<string, unknown>[];
/** The acting user's connected-service token for ctx.oauth.getAccessToken (default null). */
oauthAccessToken?: string | null;
/** Daily cap on ctx.ai.complete/ctx.ai.extract calls, mirroring the host's
* per-plugin daily-budget broker (daily-budget.ts). Default 200; `0` disables
* the broker entirely, so the FIRST call fails with the exhaustion string.
* Unlike the real host, this is a plain in-memory counter for the life of this
* mock host: it never rolls over at UTC midnight — there is no timer here, and
* no wall-clock window. A test that wants to see the budget "reset" should
* create a fresh mock host rather than wait. */
aiPerDay?: number;
/** Same idea as `aiPerDay`, for ctx.notify.send. Default 100; `0` disables the
* broker (first call fails with the exhaustion string). See `aiPerDay` for the
* no-rollover caveat — this counter never resets on its own either. */
notifyPerDay?: number;
}
/** Drives a plugin's OWN entry points against the mock ctx — the missing half of a
@@ -234,6 +246,13 @@ function settingsBlob(src: Record<string, unknown> | undefined, what: string): R
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
const TRIP_STR_LIMITS: Record<string, number> = { title: 200, description: 2000 };
// ctx.meta quotas, mirrored from meta.rpc.ts's disk-DoS guard: value size (characters
// of the serialized JSON, matching the host — UTF-16 code units, not UTF-8 bytes),
// key length, and keys per (plugin, entity).
const META_VALUE_MAX = 64 * 1024;
const META_KEY_MAX = 256;
const META_KEYS_MAX = 100;
export function createMockHost(opts: MockHostOptions = {}): MockHost {
const grants = new Set(opts.grants ?? []);
const calls: MockHost['calls'] = [];
@@ -372,6 +391,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
const metaStore: Record<string, unknown> = {};
const metaKey = (et: string, eid: number, key: string) => `${et}:${eid}:${key}`;
// Per-host daily budgets for ai.*/notify.send, mirroring daily-budget.ts. Plain
// counters, no timers: this mock never rolls the window over at UTC midnight (see
// the MockHostOptions docblocks) — a test wanting a fresh budget creates a new host.
const aiPerDay = opts.aiPerDay ?? 200;
const notifyPerDay = opts.notifyPerDay ?? 100;
let aiUsed = 0;
let notifyUsed = 0;
const buildCtx = (actingUserId: number | undefined): PluginContext => {
const requireActingUser = (): number => {
if (actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: this call requires an authenticated user context');
@@ -816,6 +843,8 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
if (!input.link.startsWith('/') || input.link.startsWith('//')) throw new Error('link must be an in-app path starting with /');
link = input.link.slice(0, 512);
}
if (notifyUsed >= notifyPerDay) throw new Error('daily notification budget exhausted (resets at UTC midnight)');
notifyUsed += 1;
notifications.push({ title, body, ...(link ? { link } : {}), scope, targetId: input.targetId });
return { sent: true };
},
@@ -823,10 +852,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
ai: {
async complete() {
need('ai:invoke', 'ai.complete');
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
aiUsed += 1;
return { text: opts.aiText ?? '' };
},
async extract() {
need('ai:invoke', 'ai.extract');
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
aiUsed += 1;
return { results: opts.aiResults ?? [] };
},
},
@@ -1351,7 +1384,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
async set(entityType, entityId, key, value) {
need('db:meta', 'meta.set');
metaGate(entityType, entityId);
metaStore[metaKey(entityType, entityId, key)] = value ?? null;
// Quota order mirrors meta.rpc.ts: key length -> value size -> key count.
if (key.length > META_KEY_MAX) throw new Error(`metadata key too long (>${META_KEY_MAX} chars)`);
const json = JSON.stringify(value ?? null);
// Matches the host exactly: json.length (UTF-16 code units of the serialized
// JSON), not Buffer.byteLength (UTF-8 bytes) — see meta.rpc.ts:71.
if (json.length > META_VALUE_MAX) {
throw new Error(`metadata value too large (>${META_VALUE_MAX} bytes)`);
}
const fullKey = metaKey(entityType, entityId, key);
if (!(fullKey in metaStore)) {
const prefix = `${entityType}:${entityId}:`;
const count = Object.keys(metaStore).filter((k) => k.startsWith(prefix)).length;
if (count >= META_KEYS_MAX) throw new Error(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
}
metaStore[fullKey] = value ?? null;
return { key, value: value ?? null };
},
async list(entityType, entityId) {
+36 -3
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import { validateManifest } from '../src/index.js';
import { scaffold } from '../src/cli/create.js';
import { PERMISSION_CATALOG, PERMISSION_FAMILIES, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
import { PERMISSION_CATALOG, PERMISSION_FAMILIES, PICKER_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
// The authoritative set — what the HOST accepts at activation. The picker must match it.
import { KNOWN_PERMISSIONS as MANIFEST_PERMISSIONS } from '../src/manifest.js';
import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js';
@@ -47,7 +47,7 @@ describe('permission catalog', () => {
// list the host actually honours — so a new TREK permission fails here until `create`
// can offer it.
it('offers every permission the host accepts — no more, no less', () => {
expect([...KNOWN_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort());
expect([...PICKER_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort());
});
it('describes every permission, and files it in exactly one family', () => {
@@ -57,7 +57,7 @@ describe('permission catalog', () => {
.toHaveLength(1);
}
// No duplicates across families — a permission listed twice would render twice.
expect(new Set(KNOWN_PERMISSIONS).size).toBe(KNOWN_PERMISSIONS.length);
expect(new Set(PICKER_PERMISSIONS).size).toBe(PICKER_PERMISSIONS.length);
});
it('every family is non-empty and described (an empty one is a dead prompt entry)', () => {
@@ -245,3 +245,36 @@ describe('capabilities.notificationChannel validation', () => {
expect(r.errors!.join(' ')).toMatch(/requires the "hook:notification-channel" permission/);
});
});
describe('scaffold oauth:client settings', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oauth-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const OAUTH_KEYS = ['oauth_authorize_url', 'oauth_token_url', 'oauth_scopes', 'oauth_client_id', 'oauth_client_secret'];
it('scaffolds the five instance-scoped broker settings when oauth:client is granted', () => {
scaffold('oauth-plug', 'integration', tmp, { permissions: ['db:own', 'oauth:client'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'oauth-plug', 'trek-plugin.json'), 'utf8'));
const settings = m.settings as Array<{ key: string; secret?: boolean; scope?: string }>;
for (const key of OAUTH_KEYS) {
expect(settings.some((s) => s.key === key), `missing setting "${key}"`).toBe(true);
}
const secret = settings.find((s) => s.key === 'oauth_client_secret')!;
expect(secret.secret).toBe(true);
for (const key of OAUTH_KEYS) {
const s = settings.find((x) => x.key === key)!;
expect(s.scope, `${key}.scope`).toBe('instance');
}
expect(validateManifest(m).ok).toBe(true);
});
it('scaffolds none of the broker settings without oauth:client', () => {
scaffold('plain-oauth-plug', 'integration', tmp, { permissions: ['db:own'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'plain-oauth-plug', 'trek-plugin.json'), 'utf8'));
const settings = (m.settings ?? []) as Array<{ key: string }>;
for (const key of OAUTH_KEYS) {
expect(settings.some((s) => s.key === key), `unexpected setting "${key}"`).toBe(false);
}
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { validateManifest } from '../src/manifest.js';
describe('validateManifest normalized output', () => {
it('normalized manifest keeps every recognized field', () => {
const input = {
id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'widget', trek: '>=4.0.0 <5.0.0',
permissions: ['db:own'], operatorEgress: false, icon: 'Bell', author: 'A', description: 'Does things.',
homepage: 'https://example.com', tags: ['fun'], license: 'MIT',
capabilities: { widget: { title: 'W', slot: 'hero' }, provides: ['getState'] },
settings: [{ key: 'mode', scope: 'user', options: ['a', 'b'] }],
actions: [{ key: 'ping', label: 'Ping' }],
};
const r = validateManifest(input);
expect(r.ok).toBe(true);
const m = r.manifest!;
expect(m.capabilities?.widget?.slot).toBe('hero');
expect(m.settings?.[0].options).toEqual(['a', 'b']);
expect(m.actions?.[0]).toEqual({ key: 'ping', label: 'Ping' });
expect(m.icon).toBe('Bell');
expect(m.operatorEgress).toBe(false);
expect(m.nativeModules).toBe(false); // still forced
expect(m.apiVersion).toBe(1); // still defaulted
expect(m.requiredAddons).toEqual([]);
});
it('accepts routeProfiles icon', () => {
const r = validateManifest({
id: 'rt-plug', name: 'R', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0',
permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'hike', label: 'Hiking', icon: 'Mountain' }] },
});
expect(r.ok).toBe(true);
expect(r.manifest!.capabilities?.routeProfiles?.[0].icon).toBe('Mountain');
});
it('drops a non-string routeProfiles icon instead of rejecting the manifest', () => {
const r = validateManifest({
id: 'rt-plug', name: 'R', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0',
permissions: ['hook:route-provider'], capabilities: { routeProfiles: [{ id: 'hike', label: 'Hiking', icon: 42 }] },
});
expect(r.ok).toBe(true);
expect(r.manifest!.capabilities?.routeProfiles?.[0].icon).toBeUndefined();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { validateManifest } from '../src/manifest.js';
const base = () => ({ id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' });
const withSetting = (extra: object) => ({ ...base(), settings: [{ key: 'mode', ...extra }] });
describe('validateManifest settings[].options / settings[].oauth', () => {
it('accepts string/number option lists', () => {
expect(validateManifest(withSetting({ options: ['a', 'b'] })).ok).toBe(true);
expect(validateManifest(withSetting({ options: [1, 2] })).ok).toBe(true);
});
it('accepts {value,label} options incl. value 0', () => {
expect(validateManifest(withSetting({ options: [{ value: 0, label: 'Zero' }] })).ok).toBe(true);
});
it('rejects a non-array options', () => {
const r = validateManifest(withSetting({ options: 'a,b' }));
expect(r.ok).toBe(false);
expect(r.errors).toContain('settings option list must be an array');
});
it('rejects an option with empty value', () => {
const r = validateManifest(withSetting({ options: [{ value: '', label: 'x' }] }));
expect(r.errors).toContain('settings option must have a non-empty "value"');
});
it('rejects a garbage option entry', () => {
const r = validateManifest(withSetting({ options: [true] }));
expect(r.errors).toContain('invalid settings option true (expected a string or { value, label })');
});
it('rejects a non-object oauth', () => {
expect(validateManifest(withSetting({ oauth: 'x' })).errors).toContain('settings oauth must be an object');
});
it('rejects non-string oauth paths, accepts valid oauth', () => {
expect(validateManifest(withSetting({ oauth: { initPath: 3 } })).errors).toContain('settings oauth.initPath must be a string');
expect(validateManifest(withSetting({ oauth: { initPath: '/oauth/init' } })).ok).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { createMockHost } from '../src/index.js';
/**
* Mock-host fidelity for the two host-mediated daily budgets (ai.complete/extract and
* notify.send, daily-budget.ts) and the ctx.meta quotas (meta.rpc.ts). Error strings
* and check order are copied verbatim from the server so a plugin author sees the
* exact same failure locally that production would give them.
*/
describe('daily budgets', () => {
it('exhausts the AI budget', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 2 });
await host.ctx.ai.complete('hi');
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('ai.extract shares the same per-host AI budget as ai.complete', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 1 });
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.extract('text', {})).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('aiPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 0 });
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('defaults to 200/day for ai, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1 });
for (let i = 0; i < 200; i++) await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('exhausts the notify budget', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 1,
});
await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('notifyPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 0,
});
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('defaults to 100/day for notify, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['notify:send'], actingUserId: 1, trips: { 1: { members: [1] } } });
for (let i = 0; i < 100; i++) await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
});
describe('ctx.meta quotas', () => {
const fixture = () =>
createMockHost({ grants: ['db:meta'], actingUserId: 1, trips: { 1: { members: [1] } } });
it('meta value quota: 65536 bytes passes, 65537 rejects', async () => {
const { ctx } = fixture();
// Size the payload via Buffer.byteLength(JSON.stringify(v)), same as the server's
// notion of "serialized JSON bytes" — a bare string serializes with 2 quote bytes,
// so budget those into the repeat count.
const okValue = 'x'.repeat(65536 - 2);
expect(Buffer.byteLength(JSON.stringify(okValue), 'utf8')).toBe(65536);
await expect(ctx.meta.set('trip', 1, 'k', okValue)).resolves.toEqual({ key: 'k', value: okValue });
const tooBig = 'x'.repeat(65537 - 2);
expect(Buffer.byteLength(JSON.stringify(tooBig), 'utf8')).toBe(65537);
await expect(ctx.meta.set('trip', 1, 'k2', tooBig)).rejects.toThrow('metadata value too large (>65536 bytes)');
});
it('meta key length: 256 chars passes, 257 rejects', async () => {
const { ctx } = fixture();
const okKey = 'k'.repeat(256);
await expect(ctx.meta.set('trip', 1, okKey, 'v')).resolves.toEqual({ key: okKey, value: 'v' });
const tooLongKey = 'k'.repeat(257);
await expect(ctx.meta.set('trip', 1, tooLongKey, 'v')).rejects.toThrow('metadata key too long (>256 chars)');
});
it('meta key count: 100 keys per (plugin, entity) passes, the 101st rejects', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(ctx.meta.set('trip', 1, 'key-100', 100)).rejects.toThrow(
'too many metadata keys on this trip (max 100)',
);
});
it('overwriting an existing key never counts against the 100-key cap', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
// The entity already has 100 keys; updating one of them must still succeed.
await expect(ctx.meta.set('trip', 1, 'key-0', 'updated')).resolves.toEqual({ key: 'key-0', value: 'updated' });
});
it('checks in server order: key length before value size before key count', async () => {
const { ctx } = fixture();
// A key that is simultaneously too long AND paired with an oversized value must
// fail on the key-length check first.
const tooLongKey = 'k'.repeat(257);
const tooBigValue = 'x'.repeat(70000);
await expect(ctx.meta.set('trip', 1, tooLongKey, tooBigValue)).rejects.toThrow('metadata key too long (>256 chars)');
// Fill the entity to the cap, then a value that is ALSO too large must fail on
// the value-size check (raised before the key-count check ever runs).
const full = fixture();
for (let i = 0; i < 100; i++) {
await full.ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(full.ctx.meta.set('trip', 1, 'new-key', tooBigValue)).rejects.toThrow(
'metadata value too large (>65536 bytes)',
);
});
});
+11 -1
View File
@@ -15,7 +15,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HOOK_PERMISSION } from '../src/permissions.js';
import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from '../src/index.js';
import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES, EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS } from '../src/index.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const serverPlugins = path.resolve(here, '../../server/src/nest/plugins');
@@ -86,3 +86,13 @@ describe.skipIf(!fs.existsSync(hostFrame))('parity with the host frame', () => {
}
});
});
describe.skipIf(!inMonorepo)('core event catalog', () => {
it('event snapshot grants are known read permissions on known families', () => {
expect(EVENT_FAMILIES.length).toBeGreaterThan(0);
for (const [family, perm] of Object.entries(EVENT_SNAPSHOT_GRANT)) {
expect(EVENT_FAMILIES).toContain(family);
expect(KNOWN_PERMISSIONS).toContain(perm);
}
});
});
+128 -2
View File
@@ -6,6 +6,11 @@ import path from 'node:path';
// publish shells out to git + gh; drive those from here so we can assert what it WOULD run.
const calls: Array<{ bin: string; args: string[] }> = [];
let releaseExistsOnRemote = false;
/** The commit the local tag points at; null = no local tag. `git tag` mutates it like git would. */
let localTagSha: string | null = null;
let remoteTagOnOrigin = false;
let failPrCreate = false;
const HEAD_SHA = 'headfeedheadfeedheadfeedheadfeedheadfeed';
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
@@ -14,8 +19,24 @@ vi.mock('node:child_process', () => ({
if (!releaseExistsOnRemote) throw new Error('release not found');
return Buffer.from('');
}
// `git rev-parse <tag>^{commit}` — pretend the tag does not exist yet.
if (bin === 'git' && args.includes('rev-parse')) throw new Error('unknown revision');
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create' && failPrCreate) throw new Error('pr create exploded');
if (bin === 'gh' && args[0] === 'api') return Buffer.from('someone');
if (bin === 'git' && args.includes('ls-remote')) {
return Buffer.from(remoteTagOnOrigin ? `${HEAD_SHA}\trefs/tags/v1.0.0` : '');
}
if (bin === 'git' && args.includes('tag')) {
// Mirror git's behaviour so later rev-parses see what the run did.
const i = args.indexOf('tag');
if (args[i + 1] === '-d') localTagSha = null;
else localTagSha = HEAD_SHA; // `git tag <t>` and `git tag -f <t>` both land on HEAD
return Buffer.from('');
}
if (bin === 'git' && args.includes('rev-parse')) {
const target = args[args.indexOf('rev-parse') + 1];
if (target === 'HEAD') return Buffer.from(HEAD_SHA);
if (localTagSha === null) throw new Error('unknown revision');
return Buffer.from(localTagSha);
}
return Buffer.from('');
},
}));
@@ -24,6 +45,12 @@ const { publishPlugin } = await import('../src/cli/publish.js');
const { scaffold } = await import('../src/cli/create.js');
const { makePublishable } = await import('./helpers.js');
beforeEach(() => {
localTagSha = null;
remoteTagOnOrigin = false;
failPrCreate = false;
});
describe('publish — a released artifact is immutable', () => {
let tmp: string;
let dir: string;
@@ -139,6 +166,105 @@ describe('publish — the local gates run before anything is cut', () => {
* cut at step 3 — so the author learned it with their tag already burned, for a fact that was
* knowable before a single byte was packed. The check belongs in step 1, and this pins it there.
*/
/**
* The cleanup half of the immutability story. Step 3 creates a local tag, pushes it, and cuts
* the release; when step 4/5 then failed, all three were stranded and the author had to delete
* them by hand before re-running. A failed publish now rolls back exactly what IT created —
* and only that: anything that existed before the run (a merged release, a pre-pushed tag) is
* never touched.
*/
describe('publish — a failed run rolls back what it created', () => {
let tmp: string;
let dir: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-rollback-'));
scaffold('rollback-plug', 'integration', tmp);
dir = path.join(tmp, 'rollback-plug');
makePublishable(dir);
});
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const run = (opts: { force?: boolean; keepRelease?: boolean } = {}) => publishPlugin({
dir, repo: 'someone/trek-plugin-rollback-plug', tag: 'v1.0.0',
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {}, ...opts,
});
it('deletes the release, remote tag, and local tag this run created', async () => {
failPrCreate = true;
await expect(run()).rejects.toThrow(/rolled back/i);
expect(calls.some((c) => c.bin === 'gh' && c.args[0] === 'release' && c.args[1] === 'delete' && c.args.includes('v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes(':refs/tags/v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
it('--keep-release leaves everything in place and prints the manual cleanup', async () => {
failPrCreate = true;
await expect(run({ keepRelease: true })).rejects.toThrow(/gh release delete/);
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
});
it('never deletes a release or remote tag that existed BEFORE the run', async () => {
releaseExistsOnRemote = true;
remoteTagOnOrigin = true;
failPrCreate = true;
await run({ force: true }).catch(() => {});
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
// the local tag WAS created by this run, so that one goes
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
});
/**
* The stale-tag trap: a failed publish leaves a tag; the author commits a fix and re-runs; the
* old flow silently reused the stale tag and released the UNFIXED code. A tag that does not
* point at HEAD and has no release is debris — move it. One that HAS a release is immutable.
*/
describe('publish — a stale tag from a failed run is moved, not reused', () => {
let tmp: string;
let dir: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-stale-'));
scaffold('stale-plug', 'integration', tmp);
dir = path.join(tmp, 'stale-plug');
makePublishable(dir);
});
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
const run = () => publishPlugin({
dir, repo: 'someone/trek-plugin-stale-plug', tag: 'v1.0.0',
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {},
});
it('re-points a debris tag (no release) at HEAD and force-pushes it', async () => {
localTagSha = 'stalestalestalestalestalestalestalestale';
await run().catch(() => {});
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-f'))).toBe(true);
const push = calls.find((c) => c.bin === 'git' && c.args.includes('push') && c.args.includes('v1.0.0'));
expect(push?.args).toContain('--force');
});
it('refuses a mismatched tag whose release already exists', async () => {
localTagSha = 'stalestalestalestalestalestalestalestale';
releaseExistsOnRemote = true;
await expect(run()).rejects.toThrow(/points at/i);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('-f'))).toBe(false);
});
it('reuses a tag already on HEAD without touching it', async () => {
localTagSha = HEAD_SHA;
await run().catch(() => {});
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && !c.args.includes('-d'))).toBe(false);
});
});
describe('publish — a signed plugin cannot quietly go unsigned', () => {
let tmp: string;
let dir: string;
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { generateKeypair, loadPrivateKey, publicKeyBase64, verifyArtifact } from '../src/cli/sign.js';
import { retroSignVersions } from '../src/cli/retro-sign.js';
const V1_BYTES = Buffer.from('the exact bytes of the v1.0.0 release asset');
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
describe('retro-sign — first signed update signs the older versions too', () => {
let tmp: string;
let keyPath: string;
let publicKey: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'retro-sign-'));
keyPath = path.join(tmp, 'signing.key');
publicKey = generateKeypair(keyPath).publicKey;
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const entry = (v1: Partial<{ sha256: string; signature: string }> = {}) => ({
id: 'retro-plug',
authorPublicKey: publicKey,
versions: [
{ version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip', sha256: 'deadbeef', signature: 'already-signed' },
{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), ...v1 },
],
});
it('downloads, verifies, and signs each unsigned older version', async () => {
const e = entry();
const signed = await retroSignVersions(e, keyPath, () => {});
expect(signed).toEqual(['1.0.0']);
const sig = e.versions[1].signature!;
expect(verifyArtifact(V1_BYTES, sig, publicKey)).toBe(true);
expect(e.versions[0].signature).toBe('already-signed'); // untouched
});
it('REFUSES when the downloaded bytes no longer hash to the pinned sha256', async () => {
const e = entry({ sha256: 'not-the-real-hash' });
await expect(retroSignVersions(e, keyPath, () => {})).rejects.toThrow(/1\.0\.0.*sha256/is);
expect(e.versions[1].signature).toBeUndefined();
});
it('REFUSES a key that does not match the entry authorPublicKey', async () => {
const otherKey = path.join(tmp, 'other.key');
generateKeypair(otherKey);
await expect(retroSignVersions(entry(), otherKey, () => {})).rejects.toThrow(/key/i);
});
it('is a no-op for an unsigned entry or one with nothing to sign', async () => {
const unsignedEntry = { id: 'x', versions: [{ version: '1.0.0', downloadUrl: 'u', sha256: 'h' }] };
expect(await retroSignVersions(unsignedEntry, keyPath, () => {})).toEqual([]);
const fullySigned = entry({ signature: 'sig' });
expect(await retroSignVersions(fullySigned, keyPath, () => {})).toEqual([]);
});
it('names every version whose artifact could not be fetched', async () => {
const e = entry();
e.versions[1].downloadUrl = 'https://github.com/x/y/releases/download/v9.9.9/plugin.zip'; // 404s
await expect(retroSignVersions(e, keyPath, () => {})).rejects.toThrow(/1\.0\.0/);
});
it('key sanity: publicKeyBase64 round-trips through loadPrivateKey', () => {
expect(publicKeyBase64(loadPrivateKey(keyPath))).toBe(publicKey);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
const calls: Array<{ bin: string; args: string[] }> = [];
let failSync = false;
/** Seeded into the fake fork clone as the plugin's existing registry entry (update case). */
let existingEntry: unknown = null;
let lastCloneDir = '';
// submit's git()/gh() run execFileSync with encoding:'utf8', which returns STRINGS — the
// mock must too, or `.trim()` explodes on a Buffer.
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
calls.push({ bin, args });
if (bin === 'gh' && args[0] === 'api') return 'someone';
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'sync' && failSync) throw new Error('fork has diverged');
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'clone') {
lastCloneDir = args[3];
if (existingEntry !== null) {
const p = path.join(lastCloneDir, 'registry', 'plugins', `${(existingEntry as { id: string }).id}.json`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(existingEntry, null, 2));
}
return '';
}
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create') return 'https://github.com/liketrek/TREK-Plugins/pull/999';
return '';
},
}));
const { submitEntry } = await import('../src/cli/submit.js');
const { generateKeypair } = await import('../src/cli/sign.js');
const { verifyArtifact } = await import('../src/cli/sign.js');
const V1_BYTES = Buffer.from('old release asset bytes');
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
describe('submit — fork sync and retro-signing', () => {
let tmp: string;
let keyPath: string;
let publicKey: string;
beforeEach(() => {
calls.length = 0;
failSync = false;
existingEntry = null;
lastCloneDir = '';
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-'));
keyPath = path.join(tmp, 'signing.key');
publicKey = generateKeypair(keyPath).publicKey;
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const freshEntry = (signed = false) => ({
id: 'sync-plug',
name: 'Sync Plug',
...(signed ? { authorPublicKey: publicKey } : {}),
versions: [{
version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
sha256: 'deadbeef', ...(signed ? { signature: 'fresh-sig' } : {}),
}],
});
it('fast-forwards the fork from the registry before branching', async () => {
await submitEntry(freshEntry(), {});
const sync = calls.find((c) => c.bin === 'gh' && c.args[0] === 'repo' && c.args[1] === 'sync');
expect(sync?.args).toContain('someone/TREK-Plugins');
expect(sync?.args).toContain('--source');
expect(sync?.args).toContain('liketrek/TREK-Plugins');
});
it('a diverged fork warns but does not stop the submit', async () => {
failSync = true;
const { prUrl } = await submitEntry(freshEntry(), {});
expect(prUrl).toContain('/pull/999');
});
it('retro-signs unsigned older versions on the first signed update', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await submitEntry(freshEntry(true), { signKeyPath: keyPath, keep: true });
const written = JSON.parse(fs.readFileSync(path.join(lastCloneDir, 'registry', 'plugins', 'sync-plug.json'), 'utf8')) as {
versions: Array<{ version: string; signature?: string }>;
};
const v1 = written.versions.find((v) => v.version === '1.0.0')!;
expect(v1.signature).toBeDefined();
expect(verifyArtifact(V1_BYTES, v1.signature!, publicKey)).toBe(true);
fs.rmSync(lastCloneDir, { recursive: true, force: true });
});
it('refuses a first signed update when no key is available to retro-sign the older versions', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await expect(submitEntry(freshEntry(true), {})).rejects.toThrow(/--sign/);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// unrelease shells out to git + gh; drive those from here so we can assert what it deletes.
const calls: Array<{ bin: string; args: string[] }> = [];
let releaseExistsOnRemote = false;
let remoteTagOnOrigin = false;
let localTagExists = false;
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
calls.push({ bin, args });
if (bin === 'gh' && args[0] === 'release' && args[1] === 'view') {
if (!releaseExistsOnRemote) throw new Error('release not found');
return Buffer.from('');
}
if (bin === 'git' && args.includes('ls-remote')) {
return Buffer.from(remoteTagOnOrigin ? 'abc\trefs/tags/v1.0.0' : '');
}
if (bin === 'git' && args.includes('rev-parse')) {
if (!localTagExists) throw new Error('unknown revision');
return Buffer.from('abc');
}
return Buffer.from('');
},
}));
const { unrelease } = await import('../src/cli/unrelease.js');
/** The published registry index the immutability guard consults. */
let indexBody: unknown = { plugins: [] };
let indexFails = false;
describe('unrelease — delete a stranded tag + release safely', () => {
let tmp: string;
beforeEach(() => {
calls.length = 0;
releaseExistsOnRemote = false;
remoteTagOnOrigin = false;
localTagExists = false;
indexBody = { plugins: [] };
indexFails = false;
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unrelease-'));
fs.writeFileSync(path.join(tmp, 'trek-plugin.json'), JSON.stringify({ id: 'doomed-plug', version: '1.0.0' }));
vi.stubGlobal('fetch', async () => {
if (indexFails) throw new Error('offline');
return { ok: true, json: async () => indexBody } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const run = (yes?: boolean) => unrelease({
dir: tmp, tag: 'v1.0.0', repo: 'someone/trek-plugin-doomed-plug', yes, log: () => {},
});
it('REFUSES when that version is published in the registry — those artifacts are immutable', async () => {
indexBody = { plugins: [{ id: 'doomed-plug', versions: [{ version: '1.0.0' }] }] };
releaseExistsOnRemote = true;
await expect(run(true)).rejects.toThrow(/published in the registry/i);
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
});
it('deletes the release, remote tag, and local tag when they exist', async () => {
releaseExistsOnRemote = true;
remoteTagOnOrigin = true;
localTagExists = true;
const { deleted } = await run();
expect(deleted).toEqual(['release', 'remote tag', 'local tag']);
expect(calls.some((c) => c.bin === 'gh' && c.args[0] === 'release' && c.args[1] === 'delete' && c.args.includes('--yes'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes(':refs/tags/v1.0.0'))).toBe(true);
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
});
it('reports what was already absent without failing', async () => {
const { deleted } = await run();
expect(deleted).toEqual([]);
});
it('when the registry cannot be checked, refuses without --yes and proceeds with it', async () => {
indexFails = true;
localTagExists = true;
await expect(run()).rejects.toThrow(/--yes/);
const { deleted } = await run(true);
expect(deleted).toEqual(['local tag']);
});
it('a different published version of the same plugin does not block this tag', async () => {
indexBody = { plugins: [{ id: 'doomed-plug', versions: [{ version: '0.9.0' }] }] };
localTagExists = true;
const { deleted } = await run();
expect(deleted).toEqual(['local tag']);
});
});