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