From 6c8fcb29ded4a89f394b63cce1f59c1616cc1ffc Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 18:41:22 +0200 Subject: [PATCH] fix(sdk) rework the sdk helpers and DX/UX --- plugin-sdk/README.md | 119 ++++- plugin-sdk/src/cli/checks/context.ts | 65 +++ plugin-sdk/src/cli/checks/index.ts | 71 +++ plugin-sdk/src/cli/checks/network.ts | 386 ++++++++++++++++ plugin-sdk/src/cli/checks/offline.ts | 630 ++++++++++++++++++++++++++ plugin-sdk/src/cli/checks/readme.ts | 81 ++++ plugin-sdk/src/cli/checks/report.ts | 110 +++++ plugin-sdk/src/cli/checks/types.ts | 216 +++++++++ plugin-sdk/src/cli/create.ts | 142 +++++- plugin-sdk/src/cli/dev.ts | 17 +- plugin-sdk/src/cli/entry.ts | 27 ++ plugin-sdk/src/cli/help.ts | 241 ++++++++++ plugin-sdk/src/cli/menu.ts | 21 +- plugin-sdk/src/cli/pack.ts | 18 +- plugin-sdk/src/cli/preflight.ts | 245 ++-------- plugin-sdk/src/cli/publish.ts | 102 ++++- plugin-sdk/src/cli/shot.ts | 176 +++++++ plugin-sdk/src/cli/signing.ts | 147 ++++++ plugin-sdk/src/cli/status.ts | 87 ++++ plugin-sdk/src/cli/trek-plugin.ts | 236 +++++++--- plugin-sdk/src/cli/validate.ts | 135 ++---- plugin-sdk/test/checks-parity.test.ts | 205 +++++++++ plugin-sdk/test/checks.test.ts | 260 +++++++++++ plugin-sdk/test/helpers.ts | 48 ++ plugin-sdk/test/publish.test.ts | 108 +++++ plugin-sdk/test/sdk.test.ts | 67 ++- plugin-sdk/test/shot.test.ts | 48 ++ plugin-sdk/test/signing.test.ts | 52 +++ wiki/Plugin-Development.md | 125 +++-- wiki/Plugin-Publishing.md | 151 ++++-- 30 files changed, 3808 insertions(+), 528 deletions(-) create mode 100644 plugin-sdk/src/cli/checks/context.ts create mode 100644 plugin-sdk/src/cli/checks/index.ts create mode 100644 plugin-sdk/src/cli/checks/network.ts create mode 100644 plugin-sdk/src/cli/checks/offline.ts create mode 100644 plugin-sdk/src/cli/checks/readme.ts create mode 100644 plugin-sdk/src/cli/checks/report.ts create mode 100644 plugin-sdk/src/cli/checks/types.ts create mode 100644 plugin-sdk/src/cli/help.ts create mode 100644 plugin-sdk/src/cli/shot.ts create mode 100644 plugin-sdk/src/cli/signing.ts create mode 100644 plugin-sdk/src/cli/status.ts create mode 100644 plugin-sdk/test/checks-parity.test.ts create mode 100644 plugin-sdk/test/checks.test.ts create mode 100644 plugin-sdk/test/helpers.ts create mode 100644 plugin-sdk/test/shot.test.ts create mode 100644 plugin-sdk/test/signing.test.ts diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index b5435eff7..771b087e1 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -2,19 +2,27 @@ The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins. +The path is four commands: **`create` → `dev` → `status` → `publish`**. Everything +else the CLI can do is a step one of those already does for you. + ## Scaffold a plugin ```bash npx trek-plugin-sdk # no command? a guided menu of everything below -npx trek-plugin-sdk create # interactive wizard (id, location, type, permissions) +npx trek-plugin-sdk create # interactive wizard (id, type, icon, permissions, egress) npx trek-plugin-sdk create my-plugin --type widget # or non-interactive cd my-plugin ``` -The wizard also offers to initialize a git repo and install dependencies for you. -In a non-interactive shell (CI, pipes) every command stays flag-driven with plain -output — no prompts, and machine output (`entry` JSON, `pack --json`, PR URLs) stays -on stdout. +The wizard asks for the id, type, icon (validated against lucide), permissions, +egress hosts and required addons, and offers to initialize a git repo and install +dependencies for you. In a non-interactive shell (CI, pipes) every command stays +flag-driven with plain output — no prompts, and machine output (`entry` JSON, +`pack --json`, PR URLs) stays on stdout. + +What you get **runs and packs immediately** — but it is not publishable yet: the +README is a template and there is no screenshot. That is deliberate. Writing those +is your job, and `status` tells you exactly what is left. ## Develop with a live reload loop @@ -32,6 +40,23 @@ theme/accent/appearance toggle (`trek.invoke()` is proxied to your routes). Hit as an unauthenticated request with `?_anon=1`. Drop a `dev-fixtures.json` (trips, users, config) next to your manifest to feed `ctx.trips` / `ctx.users`. +## Know where you are + +```bash +npx trek-plugin-sdk status # never fails — it's a map, not a gate +``` + +`status` runs every check the TREK-Plugins registry enforces that can be answered +without a network — which is nearly all of them — and prints the whole journey as a +checklist grouped by stage (Manifest, Code, Docs, Release, Repo): what passes, what +does not, and the **one** command to run next. Run it whenever you are not sure what +to do; it deliberately never exits non-zero. + +`validate` is the same checks with an exit code. It is the form for scripts and CI; +`status` is the form for humans. A plugin that passes `validate` will pass every +registry gate that does not require the tag and the release to exist — those four +run in `preflight`, which `publish` does for you. + ## Build a native UI (page / widget) The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the @@ -93,6 +118,21 @@ const { ctx, broadcasts } = createMockHost({ // degrades gracefully when a permission is missing. ``` +## Capture the screenshot + +The registry requires a screenshot that resolves to a real image, and the store card +shows it. `shot` boots the dev server, renders your plugin in the same themed frame +TREK uses, and writes a 1600×900 `docs/screenshot.png`: + +```bash +npm i -D playwright && npx playwright install chromium # once +npx trek-plugin-sdk shot # --dark for the dark theme +``` + +Playwright is deliberately **not** a dependency of this SDK — it ships a browser, and +most authors never need one. An `integration` plugin has no UI to render, so `shot` +cannot help; screenshot the TREK surface your plugin changes instead. + ## Publish — one command Commit and push your plugin to its public GitHub repo, then: @@ -101,10 +141,20 @@ Commit and push your plugin to its public GitHub repo, then: npx trek-plugin-sdk publish --repo you/repo --tag v1.0.0 ``` -`publish` does the whole release in one go: **pack** → **tag + GitHub release** -→ **preflight** (runs the registry CI checks locally) → **open the registry PR**. -If preflight finds a problem it stops *before* submitting, so a broken entry -never becomes a doomed PR. It prints the PR URL at the end. +`publish` is the whole release, in five steps and in this order: + +1. **check** — every registry gate that can be checked locally +2. **pack** — build `plugin.zip` +3. **release** — git tag, push, and cut the GitHub release with the artifact +4. **preflight** — the gates that need the tag and the release to exist +5. **submit** — open the PR against the TREK-Plugins registry + +Step 1 comes first for a reason: a GitHub release is effectively immutable, because +the registry 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. (`--no-checks` +skips step 1; it is an escape hatch for a re-run, never right on a first publish.) + +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 @@ -114,19 +164,30 @@ Prefer to drive the steps yourself? They still exist individually — `pack`, `release` (pack → GitHub release → entry), `preflight`, `submit` (opens the PR), and `entry` (just prints the JSON). -### Sign your releases (optional, recommended) +### Signing (recommended — `publish` offers it) -Give your plugin a stable identity. TREK pins your key on first install -(trust-on-first-use); afterwards an unsigned or wrong-key update is refused. +In a terminal, `publish` **asks** whether to sign, and creates the key for you if you +have none — you don't have to know `--sign` or `keygen` exist. Scripts and CI are never +prompted: pass `--sign`. + +A signature proves the artifact came from **you**, not just that its bytes match what +the registry saw. Signing is dependency-free Ed25519 over the artifact bytes. + +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. +- **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. + +So **back the key up** (`~/.trek-plugin/signing.key`). Losing it means you cannot ship an +update to your own plugin without a registry maintainer override. ```bash -npx trek-plugin-sdk keygen # once — writes ~/.trek-plugin/signing.key -npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign +npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answer the prompt ``` -Signing is dependency-free Ed25519 over the artifact bytes. **Back up the key** — -losing it means you can't ship signed updates. - ## Exports - `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`). @@ -138,19 +199,27 @@ losing it means you can't ship signed updates. ## Commands Run any of these with `npx trek-plugin-sdk ` (or the short `trek-plugin` -bin if you install the package): +bin if you install the package). `trek-plugin help ` — or +`trek-plugin --help` — prints a full page for any of them. -- `create [name] [--type t] [--interactive]` — scaffold a plugin; a wizard if you omit the name. +**The path:** + +- `create [name] [--type t] [--template blank|notification-channel] [--interactive]` — scaffold a plugin; a wizard if you omit the name. - `dev [dir] [--port 4317]` — run locally with a real request loop, SQLite `db:own`, and hot reload. -- `validate [dir]` — manifest + layout checks (a subset of registry CI, offline). -- `pack [dir] [--out plugin.zip] [--json]` — build the artifact, print `sha256` + `size`. +- `status [dir]` — where am I? what's left? Every offline registry gate as a checklist, plus the next command. Never fails. +- `publish [dir] --repo o/n --tag vX [--sign [key]] [--no-checks] [--no-preflight]` — **the lot**: check → pack → release → preflight → open the PR. In a terminal it offers to sign (and makes you a key); scripts pass `--sign`. + +**Also:** + +- `validate [dir]` — the gate: the same checks as `status`, but it exits non-zero. +- `pack [dir] [--out plugin.zip] [--json]` — build the artifact, print `sha256` + `size`. Refuses a plugin that could not *load*; does not enforce the publish gates, because packing is how you sideload a plugin to try it. +- `shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]` — capture `docs/screenshot.png`. Needs Playwright. - `keygen [--key file]` — create an Ed25519 signing key. - `sign [zip] [--key file]` — print a signature + public key for an artifact. - `entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f]` — emit the registry entry JSON. -- `preflight --repo o/n --tag vX` (or `--entry f`) — run the registry CI checks locally, over the network. -- `submit --repo o/n --tag vX [--sign [key]] [--draft]` — open the registry PR for you. -- `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, in one go. -- `publish [dir] --repo o/n --tag vX [--sign [key]] [--no-preflight]` — **the lot**: pack → tag + release → preflight → open the PR. +- `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. ## Update notice diff --git a/plugin-sdk/src/cli/checks/context.ts b/plugin-sdk/src/cli/checks/context.ts new file mode 100644 index 000000000..753a094e4 --- /dev/null +++ b/plugin-sdk/src/cli/checks/context.ts @@ -0,0 +1,65 @@ +/** + * Assemble a CheckContext from a plugin directory: read each file ONCE, hand the same + * strings to every check. Checks stay pure functions of this object, which is what makes + * them table-testable without a temp dir. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import type { CheckContext, RegistryEntry } from './types.js'; + +function readIfPresent(p: string): string | undefined { + try { + return fs.readFileSync(p, 'utf8'); + } catch { + return undefined; + } +} + +export function loadContext( + dir: string, + extra: { zipBytes?: Buffer; entry?: RegistryEntry } = {}, +): CheckContext { + const root = path.resolve(dir); + + let manifest: Record | undefined; + let manifestError: string | undefined; + const manifestRaw = readIfPresent(path.join(root, 'trek-plugin.json')); + if (manifestRaw === undefined) { + manifestError = `no trek-plugin.json in ${dir}`; + } else { + try { + // Tolerate a UTF-8 BOM. Windows editors add one, and a bare JSON.parse then fails with a + // cryptic "Unexpected token" pointing at an invisible character. Same rule as readJsonFile. + const text = manifestRaw.charCodeAt(0) === 0xfeff ? manifestRaw.slice(1) : manifestRaw; + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + manifestError = 'trek-plugin.json is not a JSON object'; + } else { + manifest = parsed as Record; + } + } catch (e) { + manifestError = 'trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : String(e)); + } + } + + return { + dir: root, + manifest, + manifestError, + readme: readIfPresent(path.join(root, 'README.md')), + clientHtml: readIfPresent(path.join(root, 'client', 'index.html')), + exists: (rel: string) => fs.existsSync(path.join(root, rel)), + zipBytes: extra.zipBytes, + entry: extra.entry, + }; +} + +/** Convenience for tests and for callers that already hold the strings. */ +export function makeContext(over: Partial & { dir?: string }): CheckContext { + const files = new Set(); + return { + dir: over.dir ?? '/plugin', + exists: (rel: string) => files.has(rel), + ...over, + }; +} diff --git a/plugin-sdk/src/cli/checks/index.ts b/plugin-sdk/src/cli/checks/index.ts new file mode 100644 index 000000000..3f6302a39 --- /dev/null +++ b/plugin-sdk/src/cli/checks/index.ts @@ -0,0 +1,71 @@ +/** + * The check registry's public face: one list, run at the depth the caller can afford. + * + * runOffline(ctx) — `status`, `validate`, and `publish`'s first step. No network, no push. + * SYNCHRONOUS, which is what keeps `packPluginDir` (it validates before it + * zips) from having to become async and dragging publish/release with it. + * runAll(ctx) — `preflight` and `publish`: the offline set PLUS the handful of things + * that genuinely need GitHub. + * + * Nothing here decides how to PRINT a report — that is ./report.ts, so that `status` (a + * checklist you read) and `validate` (an exit code a script reads) can render the same + * outcomes differently without either owning the rules. + */ +import { OFFLINE_CHECKS } from './offline.js'; +import { NETWORK_CHECKS } from './network.js'; +import type { Check, CheckContext, CheckOutcome, CheckReport, CheckResult } from './types.js'; +import { summarize } from './types.js'; + +export const ALL_CHECKS: Check[] = [...OFFLINE_CHECKS, ...NETWORK_CHECKS]; + +/** + * A check that throws is a bug in the CHECK, not a verdict on the plugin. Report it as a failure + * rather than letting it crash the command: an author who cannot run `status` at all is strictly + * worse off than one who sees "this check is broken". + */ +function threw(e: unknown): CheckResult { + return { status: 'fail', detail: 'check threw: ' + (e instanceof Error ? e.message : String(e)) }; +} + +const outcome = (check: Check, result: CheckResult): CheckOutcome => ({ + id: check.id, + stage: check.stage, + severity: check.severity, + // Most gates are the REGISTRY's, not the loader's — they stop you publishing, not building. + // Only the handful that make a plugin unloadable opt into 'artifact'. + blocks: check.blocks ?? 'publish', + title: check.title, + ...result, +}); + +/** Every gate that is a pure function of the working tree. */ +export function runOffline(ctx: CheckContext): CheckReport { + return summarize( + OFFLINE_CHECKS.map((check) => { + try { + return outcome(check, check.run(ctx)); + } catch (e) { + return outcome(check, threw(e)); + } + }), + ); +} + +/** The offline set plus the network gates. Requires ctx.entry for the network half to do anything. */ +export async function runAll(ctx: CheckContext): Promise { + const outcomes: CheckOutcome[] = runOffline(ctx).outcomes; + for (const check of NETWORK_CHECKS) { + try { + outcomes.push(outcome(check, await check.run(ctx))); + } catch (e) { + outcomes.push(outcome(check, threw(e))); + } + } + return summarize(outcomes); +} + +export { OFFLINE_CHECKS } from './offline.js'; +export { NETWORK_CHECKS, DEFAULT_REGISTRY } from './network.js'; +export * from './types.js'; +export { loadContext, makeContext } from './context.js'; +export { renderChecklist, renderPlain, summaryLine, nextStep } from './report.js'; diff --git a/plugin-sdk/src/cli/checks/network.ts b/plugin-sdk/src/cli/checks/network.ts new file mode 100644 index 000000000..ab85b16e5 --- /dev/null +++ b/plugin-sdk/src/cli/checks/network.ts @@ -0,0 +1,386 @@ +/** + * The four things that genuinely cannot be known from the working tree. + * + * Everything else the registry enforces lives in ./offline.ts and fires before you have pushed + * anything. What is left here needs GitHub or the registry repo to answer: + * + * 1. does the tag exist, and does it point at the commit the entry pins? + * 2. do the released artifact's bytes hash to the sha256 the entry pins — and do they verify + * against the author's key? + * 3. is this plugin id already bound to a different GitHub owner? + * 4. was this plugin published SIGNED before, and is this update about to drop or rotate the key? + * + * (3) and (4) are new. The registry has always enforced them and `preflight` never did, which + * made a green preflight a promise it could not keep: a signing downgrade merges nowhere and + * BRICKS THE UPDATE for every instance that already has the plugin, and the author would only + * find out at review. See scripts/validate-entry.mjs, "signing-downgrade guard". + * + * The manifest and README are ALSO re-graded here, at the pinned commit rather than in the tree. + * That is not redundant with the offline pass: an author who fills the README in and forgets to + * commit it has a green tree and a red tag, and the tag is what CI grades. + */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { listZipNames } from '../../zip.js'; +import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../verify-signature.js'; +import type { NetworkCheck, CheckContext, RegistryEntry, RegistryEntryVersion } from './types.js'; +import { pass, fail, skip } from './types.js'; +import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, images, undocumentedPermissions } from './readme.js'; + +export const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins'; + +const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; + +/** A GitHub token lifts the anonymous API rate limit. `gh` already has one; borrow it. */ +function ghToken(): string | undefined { + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + try { + return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined; + } catch { + return undefined; + } +} + +function ghHeaders(): Record { + const h: Record = { 'User-Agent': 'trek-plugin-preflight', Accept: 'application/vnd.github+json' }; + const t = ghToken(); + if (t) h.Authorization = `Bearer ${t}`; + return h; +} + +const rawUrl = (repo: string, sha: string, p: string): string => + `https://raw.githubusercontent.com/${repo}/${sha}/${p.replace(/^\.?\//, '')}`; + +/** The versions a network check grades: the newest by default, all of them with --all. */ +function targetVersions(c: CheckContext): RegistryEntryVersion[] { + const vs = c.entry?.versions ?? []; + return c.allVersions ? vs : vs.slice(0, 1); +} + +async function fetchText(url: string, headers: Record = { 'User-Agent': 'trek-plugin-preflight' }): Promise { + try { + const r = await fetch(url, { headers }); + return r.ok ? await r.text() : null; + } catch { + return null; + } +} + +// ── 1. the tag resolves to the pinned commit ──────────────────────────────────── + +const tagResolves: NetworkCheck = { + id: 'network.tag-resolves', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'Git tag exists and points at the pinned commit', + run: async (c) => { + if (!c.entry) return skip(); + const problems: string[] = []; + for (const v of targetVersions(c)) { + try { + const r = await fetch( + `https://api.github.com/repos/${c.entry.repo}/git/refs/tags/${encodeURIComponent(v.gitTag)}`, + { headers: ghHeaders() }, + ); + if (!r.ok) { + problems.push(`• ${v.version}: tag ${v.gitTag} not found on ${c.entry.repo} (${r.status}) — push the tag and cut the release`); + continue; + } + const ref = (await r.json()) as { object?: { sha?: string; type?: string } }; + let sha = ref.object?.sha; + // An annotated tag's ref points at the TAG object, not the commit — deref it, or every + // annotated tag looks like a mismatch. + if (ref.object?.type === 'tag' && sha) { + const tr = await fetch(`https://api.github.com/repos/${c.entry.repo}/git/tags/${sha}`, { headers: ghHeaders() }); + if (tr.ok) sha = ((await tr.json()) as { object?: { sha?: string } }).object?.sha; + } + if (sha && sha !== v.commitSha) { + problems.push(`• ${v.version}: tag ${v.gitTag} points at ${sha.slice(0, 8)}, entry pins ${v.commitSha.slice(0, 8)}`); + } + } catch (e) { + problems.push(`• ${v.version}: tag check failed: ${(e as Error).message}`); + } + } + return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass(); + }, +}; + +// ── 2. the manifest at the pinned commit matches the entry ────────────────────── + +const manifestAtCommit: NetworkCheck = { + id: 'network.manifest-at-commit', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'Manifest at the pinned commit matches the entry', + run: async (c) => { + if (!c.entry) return skip(); + const problems: string[] = []; + for (const v of targetVersions(c)) { + const text = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'trek-plugin.json')); + if (text === null) { + problems.push(`• ${v.version}: trek-plugin.json not found at ${v.commitSha.slice(0, 8)} — did you push your code?`); + continue; + } + let m: Record; + try { + m = JSON.parse(text) as Record; + } catch (e) { + problems.push(`• ${v.version}: trek-plugin.json at ${v.commitSha.slice(0, 8)} is not valid JSON: ${(e as Error).message}`); + continue; + } + const p = (msg: string) => problems.push(`• ${v.version}: ${msg}`); + if (m.id !== c.entry.id) p(`manifest id "${String(m.id)}" != entry id "${c.entry.id}"`); + if (m.version !== v.version) p(`manifest version "${String(m.version)}" != entry "${v.version}"`); + if (m.type !== c.entry.type) p(`manifest type "${String(m.type)}" != entry "${c.entry.type}"`); + const mApi = typeof m.apiVersion === 'number' ? m.apiVersion : 1; + if (mApi !== v.apiVersion) p(`manifest apiVersion ${mApi} != entry ${v.apiVersion}`); + if (m.nativeModules === true) p('manifest declares nativeModules:true (forbidden in v1)'); + if (typeof m.trek === 'string' && m.trek.trim() !== v.trek) p(`manifest trek "${m.trek.trim()}" != entry trek "${v.trek}"`); + if ((m.operatorEgress === true) !== (v.operatorEgress === true)) { + p(`manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`); + } + const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []); + if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) { + p('manifest requiredAddons != entry requiredAddons'); + } + const normDeps = (d: unknown) => + (Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort() : []); + if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) { + p('manifest pluginDependencies != entry pluginDependencies'); + } + } + return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass(); + }, +}; + +// ── 3. the released artifact ──────────────────────────────────────────────────── + +const artifactMatches: NetworkCheck = { + id: 'network.artifact', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'Released artifact downloads, hashes and verifies', + run: async (c) => { + if (!c.entry) return skip(); + const problems: string[] = []; + for (const v of targetVersions(c)) { + let bytes: Buffer; + try { + const r = await fetch(v.downloadUrl, { redirect: 'follow', headers: { 'User-Agent': 'trek-plugin-preflight' } }); + if (!r.ok) { + problems.push(`• ${v.version}: artifact download failed (${r.status}) ${v.downloadUrl}`); + continue; + } + bytes = Buffer.from(await r.arrayBuffer()); + } catch (e) { + problems.push(`• ${v.version}: artifact download failed: ${(e as Error).message}`); + continue; + } + + // 4096B of slack mirrors the registry: GitHub can pad a release asset slightly. + if (bytes.length > v.size + 4096) problems.push(`• ${v.version}: artifact is ${bytes.length}B, entry declares ${v.size}B`); + const sha = createHash('sha256').update(bytes).digest('hex'); + if (sha !== v.sha256) { + problems.push( + `• ${v.version}: sha256 mismatch — the release has ${sha.slice(0, 12)}…, the entry pins ${v.sha256.slice(0, 12)}…\n` + + ' (did you re-pack after building the entry? the entry must be built from the EXACT bytes you uploaded)', + ); + } + + // The signature proves the bytes came from the AUTHOR's key; the sha256 only proves they + // are what the registry vouches for. TREK verifies this again at install and aborts on a + // mismatch, so a bad signature merged here is an entry nobody can install. + if (v.signature && c.entry.authorPublicKey) { + try { + if (!verifyAuthorSignature(bytes, v.signature, c.entry.authorPublicKey)) { + problems.push(`• ${v.version}: author signature does not verify — signed with a different key, or re-packed after signing?`); + } + } catch (e) { + if (e instanceof SignatureError) problems.push(`• ${v.version}: signature/key is malformed: ${e.message}`); + else throw e; + } + } + + try { + const names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; + if (names.some((n) => NATIVE_RE.test(n))) { + problems.push(`• ${v.version}: artifact contains native binaries (.node / binding.gyp / prebuilds) — forbidden in v1`); + } + } catch { + /* not a zip we can read — the sha check above already speaks to the bytes */ + } + } + return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass(); + }, +}; + +// ── 4. the README at the pinned commit ────────────────────────────────────────── + +const readmeAtCommit: NetworkCheck = { + id: 'network.readme-at-commit', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'README at the pinned commit passes the registry gate', + run: async (c) => { + if (!c.entry) return skip(); + const v = c.entry.versions[0]; + if (!v) return skip(); + + const md = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'README.md')); + if (md === null) { + return fail( + `README.md not found at ${v.commitSha.slice(0, 8)}`, + 'The registry reads the README at the COMMIT, not from your working tree. Commit and push it.', + ); + } + + const problems: string[] = []; + for (const s of missingSections(md)) problems.push(`• missing required section "## ${s}"`); + for (const p of placeholders(md)) problems.push(`• template placeholder still present: "${p}"`); + const n = proseLength(md); + if (n < MIN_PROSE_CHARS) problems.push(`• only ${n}/${MIN_PROSE_CHARS} chars of prose`); + + // Resolve every image against the pinned commit. A relative path that exists in your tree + // but was never committed 404s here — which is exactly the failure this catches. + const imgs = images(md); + if (!imgs.length) problems.push('• no screenshot (the registry requires at least one real image)'); + else { + let anyOk = false; + const reasons: string[] = []; + for (const src of imgs) { + const url = /^https?:\/\//.test(src) + ? src.includes('github.com') && src.includes('/blob/') + ? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') + : src + : rawUrl(c.entry.repo, v.commitSha, src); + try { + const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } }); + const ct = r.headers.get('content-type') || ''; + if (r.ok && ct.startsWith('image/')) { + anyOk = true; + break; + } + reasons.push(`${src} → ${r.status} ${ct || 'no content-type'}`); + } catch { + reasons.push(`${src} → unreachable`); + } + } + if (!anyOk) problems.push('• no screenshot resolved to a real image:\n ' + reasons.join('\n ')); + } + + // Permission parity, against the manifest AT THE COMMIT (not the tree — they can differ). + const manifestText = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'trek-plugin.json')); + if (manifestText) { + try { + const m = JSON.parse(manifestText) as { permissions?: unknown }; + const perms = Array.isArray(m.permissions) ? m.permissions.map(String) : []; + const undoc = undocumentedPermissions(md, perms); + if (undoc.length) problems.push(`• permissions not explained in the README: ${undoc.join(', ')}`); + } catch { + /* manifest-at-commit already reports a broken manifest */ + } + } + + return problems.length + ? fail( + `${problems.length} problem(s) at ${v.commitSha.slice(0, 8)}`, + problems.join('\n') + + `\nThese are graded at the pinned commit. If your working tree looks fine, you have uncommitted changes —\ncommit, push, and re-tag.\nRequired sections: ${REQUIRED_SECTIONS.map((s) => `## ${s}`).join(', ')}.`, + ) + : pass(); + }, +}; + +// ── 5. owner binding (NEW — the registry has always enforced this) ─────────────── + +const ownerBinding: NetworkCheck = { + id: 'network.owner-binding', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'Plugin id is not bound to another GitHub owner', + run: async (c) => { + if (!c.entry) return skip(); + const registry = c.registry ?? DEFAULT_REGISTRY; + const text = await fetchText(rawUrl(registry, 'main', 'OWNERS.json')); + if (text === null) return skip('could not read OWNERS.json — the registry will still check this'); + let owners: { plugins?: Record }; + try { + owners = JSON.parse(text) as typeof owners; + } catch { + return skip('OWNERS.json is unreadable — the registry will still check this'); + } + const bound = owners.plugins?.[c.entry.id]?.boundOwner; + if (!bound) return pass('unclaimed id — it binds to you on merge'); + const mine = c.entry.repo.split('/')[0]; + return bound === mine + ? pass(`bound to ${bound}`) + : fail( + `"${c.entry.id}" is bound to "${bound}", your repo is "${mine}"`, + 'The registry binds a plugin id to the GitHub owner who first published it, so nobody can hijack an id.\n' + + 'Either pick a different id, or ask a maintainer for the allow-owner-change label if this is a genuine transfer.', + ); + }, +}; + +// ── 6. signing-downgrade guard (NEW — a green here used to hide a bricked update) ─ + +const signingDowngrade: NetworkCheck = { + id: 'network.signing-downgrade', + stage: 'release', + depth: 'network', + severity: 'error', + title: 'Update does not drop or rotate a published signing key', + run: async (c) => { + if (!c.entry) return skip(); + const registry = c.registry ?? DEFAULT_REGISTRY; + const text = await fetchText(rawUrl(registry, 'main', `registry/plugins/${c.entry.id}.json`)); + if (text === null) return pass('not published yet — nothing to downgrade from'); + let published: RegistryEntry; + try { + published = JSON.parse(text) as RegistryEntry; + } catch { + return skip('published entry is unreadable'); + } + if (!published.authorPublicKey) return pass('previously published unsigned'); + + // TREK pins the author key on FIRST INSTALL (trust on first use). Once a plugin has shipped + // signed, an unsigned update — or one signed with a different key — is REFUSED on every + // instance that already has it. Merging that would not just fail; it would strand every + // existing user on the version they have. + const problems: string[] = []; + if (!c.entry.authorPublicKey) { + problems.push( + '• this plugin was published SIGNED, but this entry has no authorPublicKey.\n' + + ' TREK refuses an unsigned update to a signed plugin — it would break the update for every existing install.\n' + + ' Sign it: pass --sign (your key should be at ~/.trek-plugin/signing.key).', + ); + } else if (c.entry.authorPublicKey !== published.authorPublicKey) { + problems.push( + '• this entry changes authorPublicKey. TREK refuses a key rotation until an admin re-trusts the plugin.\n' + + ' Sign with your ORIGINAL key, or ask a maintainer for the allow-key-change label.', + ); + } else { + // Every version must stay signed, not just the newest — TREK verifies whichever version it + // installs, so an unsigned older block is a landmine for a pinned install. + for (const v of c.entry.versions) { + if (!v.signature) problems.push(`• ${v.version} has no signature, but this is a signed plugin — TREK will refuse to install it`); + } + } + return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass('signed with the published key'); + }, +}; + +/** Every network check, in the order `preflight` runs them. */ +export const NETWORK_CHECKS: NetworkCheck[] = [ + tagResolves, + manifestAtCommit, + artifactMatches, + readmeAtCommit, + ownerBinding, + signingDowngrade, +]; diff --git a/plugin-sdk/src/cli/checks/offline.ts b/plugin-sdk/src/cli/checks/offline.ts new file mode 100644 index 000000000..8d6261dd7 --- /dev/null +++ b/plugin-sdk/src/cli/checks/offline.ts @@ -0,0 +1,630 @@ +/** + * Every gate that is a pure function of the working tree. + * + * This is most of them. See ./types.ts for why that matters: the registry grades + * `trek-plugin.json` and `README.md` at a pinned commit, but those are the same two files + * the author has open right now, so there is no reason to make them push a tag to find out. + * + * Each check answers ONE question and says what to do about it. The `fix` text is the whole + * point — a check that reports "README invalid" and stops has told the author nothing they + * can act on. + */ +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS } from '../../manifest.js'; +import { LUCIDE_ICON_NAMES } from '../../lucide-icon-names.js'; +import { listZipNames } from '../../zip.js'; +import { checkSignatureShape } from '../verify-signature.js'; +import type { OfflineCheck, CheckContext } from './types.js'; +import { pass, fail, skip } from './types.js'; +import { + REQUIRED_SECTIONS, + MIN_PROSE_CHARS, + missingSections, + placeholders, + proseLength, + images, + undocumentedPermissions, +} from './readme.js'; + +/** Mirrors the registry's native-binary scan (validate-entry.mjs) and the installer's. */ +const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; + +const str = (v: unknown): string => (typeof v === 'string' ? v : ''); +const arr = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); + +/** The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the only list that counts. */ +function grantedHosts(permissions: string[]): string[] { + return permissions.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length)).filter(Boolean); +} + +// ── manifest ──────────────────────────────────────────────────────────────────── + +const manifestPresent: OfflineCheck = { + id: 'manifest.present', + blocks: 'artifact', + stage: 'manifest', + depth: 'offline', + severity: 'error', + title: 'trek-plugin.json exists and parses', + run: (c) => + c.manifest + ? pass() + : fail( + c.manifestError ?? 'no trek-plugin.json', + 'Every plugin is rooted at a trek-plugin.json manifest. Scaffold one rather than writing it by hand.', + 'trek-plugin create', + ), +}; + +const manifestValid: OfflineCheck = { + id: 'manifest.valid', + blocks: 'artifact', + stage: 'manifest', + depth: 'offline', + severity: 'error', + title: 'Manifest passes the same rules the TREK installer applies', + run: (c) => { + if (!c.manifest) return skip(); + const r = validateManifest(c.manifest); + if (r.ok) return pass(); + return fail( + `${r.errors.length} problem${r.errors.length === 1 ? '' : 's'}`, + r.errors.map((e) => '• ' + e).join('\n'), + ); + }, +}; + +const manifestIdMatchesDir: OfflineCheck = { + id: 'manifest.id-matches-dir', + stage: 'manifest', + depth: 'offline', + severity: 'warn', + title: 'Directory name matches the plugin id', + run: (c) => { + const id = str(c.manifest?.id); + if (!id) return skip(); + const dirName = path.basename(c.dir); + return dirName === id + ? pass() + : fail( + `directory is "${dirName}", id is "${id}"`, + 'Not fatal, but the registry files your entry under the id, and every doc assumes the two agree.', + ); + }, +}; + +const manifestTrekRange: OfflineCheck = { + id: 'manifest.trek-range', + stage: 'manifest', + depth: 'offline', + severity: 'warn', + title: 'TREK version range is bounded', + run: (c) => { + const trek = str(c.manifest?.trek); + // Unsatisfiable/missing is an ERROR, but manifest.valid already reports it — don't say it twice. + if (!isSatisfiableRange(trek)) return skip(); + return isUnboundedRange(trek) + ? 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".', + ) + : pass(trek); + }, +}; + +const manifestIcon: OfflineCheck = { + id: 'manifest.icon', + stage: 'manifest', + depth: 'offline', + severity: 'error', + title: 'Icon is a real lucide name', + run: (c) => { + if (!c.manifest) return skip(); + const icon = str(c.manifest.icon); + // Absent is LEGAL — TREK falls back to Blocks and CI accepts it. But every plugin without + // an icon is the same grey square in the store, so say so. Warn-shaped, reported as a pass + // with a caveat rather than a failure, because refusing to publish over it would be absurd. + if (!icon) return pass('none — TREK will show the generic Blocks glyph'); + return LUCIDE_ICON_NAMES.has(icon) + ? pass(icon) + : fail( + `"${icon}" is not a lucide icon`, + 'TREK resolves the icon against lucide at render time and silently falls back to Blocks, so a typo is\n' + + 'invisible locally — but the registry rejects it. Pick a name from https://lucide.dev/icons (PascalCase).', + ); + }, +}; + +/** + * The registry entry's schema, applied to the manifest fields it is built from. + * + * These are cheap string-length rules, and every one of them is a gate that today fails in CI + * — AFTER the release is immutable — because nothing local looks at them. `entry.ts` copies + * `description` straight out of the manifest and defaults it to `""`, which trips the schema's + * minLength of 5 with an error that names the ENTRY, not the manifest that caused it. + */ +const manifestEntryFields: OfflineCheck = { + id: 'manifest.entry-fields', + stage: 'manifest', + depth: 'offline', + severity: 'error', + title: 'name/description/author fit the registry schema', + run: (c) => { + if (!c.manifest) return skip(); + const problems: string[] = []; + const name = str(c.manifest.name); + const description = str(c.manifest.description); + const author = str(c.manifest.author); + + if (name && (name.length < 2 || name.length > 60)) problems.push(`• name is ${name.length} chars (registry allows 2–60)`); + if (!description) problems.push('• description is missing — the registry requires one (5–200 chars); it is your store-card subtitle'); + else if (description.length < 5) problems.push(`• description is ${description.length} chars (registry requires at least 5)`); + else if (description.length > 200) problems.push(`• description is ${description.length} chars (registry allows at most 200)`); + if (!author) problems.push('• author is missing — the entry would be published as "Unknown"'); + else if (author.length > 80) problems.push(`• author is ${author.length} chars (registry allows at most 80)`); + + // Reject a name that mixes Latin with Cyrillic/Greek look-alikes — the registry treats this + // as a spoofing attempt (a plugin that looks like someone else's in the store). + if (name && /[A-Za-z]/.test(name) && /[Ѐ-ӿͰ-Ͽ]/.test(name)) { + problems.push(`• name "${name}" mixes Latin with Cyrillic/Greek characters — the registry reads that as a homoglyph spoof`); + } + + // `tags` and `homepage` are copied VERBATIM out of the manifest into the entry (entry.ts), + // and the registry's JSON schema validates both. Nothing local looked at either, so a tag with + // a capital or a space — the obvious thing to write — produced an entry CI rejected, after the + // release was cut and its bytes pinned. + const tags = Array.isArray(c.manifest.tags) ? c.manifest.tags.map(String) : []; + for (const t of tags) { + if (!/^[a-z0-9-]{2,24}$/.test(t)) { + problems.push(`• tag "${t}" is not a lowercase slug of 2–24 chars (the registry's schema rejects it)`); + } + } + if (tags.length > 8) problems.push(`• ${tags.length} tags — the registry allows at most 8`); + + const homepage = str(c.manifest.homepage); + if (homepage) { + try { + // The schema demands format:uri, which in practice means an absolute URL. + const u = new URL(homepage); + if (!u.protocol) throw new Error('no protocol'); + } catch { + problems.push(`• homepage "${homepage}" is not an absolute URL (the registry's schema requires one, e.g. https://…)`); + } + } + + return problems.length + ? fail(`${problems.length} problem${problems.length === 1 ? '' : 's'}`, problems.join('\n')) + : pass(); + }, +}; + +const manifestAddonsKnown: OfflineCheck = { + id: 'manifest.addons-known', + stage: 'manifest', + depth: 'offline', + severity: 'warn', + title: 'requiredAddons are addons TREK has', + run: (c) => { + const addons = arr(c.manifest?.requiredAddons); + if (!addons.length) return skip(); + const unknown = addons.filter((a) => !KNOWN_ADDONS.includes(a)); + return unknown.length + ? fail( + unknown.join(', '), + `Not necessarily wrong — you may be targeting a newer TREK than this SDK knows about — but an addon\nTREK does not have can never be enabled, so the plugin would never activate. Known: ${KNOWN_ADDONS.join(', ')}.`, + ) + : pass(addons.join(', ')); + }, +}; + +const manifestNoEmoji: OfflineCheck = { + id: 'manifest.no-emoji', + stage: 'manifest', + depth: 'offline', + severity: 'warn', + title: 'No emojis in name/description', + run: (c) => { + if (!c.manifest) return skip(); + const EMOJI_RE = /[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}]/u; + const hit = EMOJI_RE.test(str(c.manifest.name)) || EMOJI_RE.test(str(c.manifest.description)); + return hit + ? fail( + 'name/description contains emojis', + 'TREK renders with lucide icons and STRIPS emojis from plugin-rendered text and notifications, so they\n' + + 'simply vanish at render. Use the `icon` field with a lucide name instead.', + ) + : pass(); + }, +}; + +// ── code ──────────────────────────────────────────────────────────────────────── + +const codeServerEntry: OfflineCheck = { + id: 'code.server-entry', + blocks: 'artifact', + stage: 'code', + depth: 'offline', + severity: 'error', + title: 'server/index.js exists', + run: (c) => + c.exists('server/index.js') + ? pass() + : fail( + 'server/index.js is missing', + 'Every plugin — even a pure-UI one — is loaded from server/index.js via definePlugin(). If you build from\nTypeScript, build before packing.', + ), +}; + +/** + * The egress trap, and the reason this check exists at all. + * + * The manifest has TWO places that look like they control network access, and only one of them + * does. `spawnActivated` (server plugin-runtime.service.ts) builds the child's allow-list purely + * from `http:outbound:` PERMISSIONS, and the iframe CSP's connect-src comes from the same + * place. The manifest's `egress[]` array is never read at runtime at all — it is a declaration + * for the consent screen. + * + * So a plugin with `egress: ["api.example.com"]` and permission `http:outbound` — no host suffix + * — installs, activates, consents, and is then SILENTLY BLOCKED from the one host it exists to + * call. Nothing in the manifest validator, the packer or the registry catches it. The author + * finds out from a production timeout. + */ +const codeEgressReachable: OfflineCheck = { + id: 'code.egress-reachable', + stage: 'code', + depth: 'offline', + severity: 'error', + title: 'Every egress host has a matching http:outbound: permission', + run: (c) => { + if (!c.manifest) return skip(); + const egress = arr(c.manifest.egress); + const permissions = arr(c.manifest.permissions); + if (!egress.length) return skip(); + + const granted = grantedHosts(permissions); + const unreachable = egress.filter((h) => !granted.includes(h)); + if (!unreachable.length) return pass(`${egress.length} host${egress.length === 1 ? '' : 's'}`); + + return fail( + unreachable.join(', '), + 'TREK opens the network guard and the iframe CSP from the `http:outbound:` PERMISSIONS only — it never\n' + + 'reads egress[]. These hosts are declared but unreachable: the plugin will install, activate, and then have\n' + + 'every request to them silently blocked.\n' + + 'Add to permissions: ' + unreachable.map((h) => `"http:outbound:${h}"`).join(', '), + ); + }, +}; + +const codeEgressDeclared: OfflineCheck = { + id: 'code.egress-declared', + stage: 'code', + depth: 'offline', + severity: 'warn', + title: 'Every reachable host is declared in egress[]', + run: (c) => { + if (!c.manifest) return skip(); + const egress = arr(c.manifest.egress); + const granted = grantedHosts(arr(c.manifest.permissions)); + if (!granted.length) return skip(); + const undeclared = granted.filter((h) => !egress.includes(h)); + return undeclared.length + ? fail( + undeclared.join(', '), + 'These hosts are reachable (you granted http:outbound:) but absent from egress[]. egress[] is what the\n' + + 'admin sees on the consent screen, so the plugin is understating its own network reach. Add them to egress[].', + ) + : pass(); + }, +}; + +const codeDesignKit: OfflineCheck = { + id: 'code.design-kit', + stage: 'code', + depth: 'offline', + severity: 'warn', + title: 'Client UI inlines the TREK design kit', + run: (c) => { + if (!c.clientHtml) return skip(); + const rawSelect = /]*\bdata-trek-native\b)(\s|>)/i.test(c.clientHtml); + const usesKit = c.clientHtml.includes('') || c.clientHtml.includes('data-trek-ui'); + return rawSelect && !usesKit + ? fail( + 'a raw + diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index e3ac17917..068d2b35f 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -33,7 +33,23 @@ interface Version { trek: string; size: number; apiVersion: number; nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string; + /** + * The manifest's dependency fields, mirrored. + * + * These are NOT optional decoration. TREK resolves requiredAddons and pluginDependencies from + * the registry INDEX, before it ever downloads the artifact — so an entry that omits them + * resolves against a dependency set the code does not actually have. Both the registry's CI + * (validate-entry.mjs) and `preflight` parity-check them against the manifest at the pinned + * commit, which means an entry built without them fails for any plugin that declares one. + * + * They were missing here until now, so `requiredAddons: ["budget"]` in a manifest produced an + * entry CI rejected with "manifest requiredAddons != entry requiredAddons" — after the release + * had been cut and its bytes pinned. Emitted only when non-empty, so ordinary entries are + * byte-identical to before. + */ + requiredAddons?: string[]; pluginDependencies?: PluginDependency[]; } +interface PluginDependency { id: string; version: string } interface Entry { id: string; name: string; author: string; description: string; repo: string; homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[]; @@ -97,6 +113,17 @@ export function buildEntry(opts: { // hosts after install). Only emitted when true, so ordinary entries stay unchanged. if (manifest.operatorEgress === true) version.operatorEgress = true; + // Mirror the dependency fields. See the Version interface for why omitting these was a bug: + // TREK resolves them from the index before downloading, and CI refuses an entry that + // disagrees with the manifest. Only emitted when non-empty — an empty array and an absent + // one normalise identically on both sides of the parity check. + const requiredAddons = Array.isArray(manifest.requiredAddons) ? manifest.requiredAddons.map(String) : []; + if (requiredAddons.length) version.requiredAddons = requiredAddons; + const pluginDependencies = Array.isArray(manifest.pluginDependencies) + ? (manifest.pluginDependencies as PluginDependency[]).map((d) => ({ id: String(d?.id), version: String(d?.version) })) + : []; + if (pluginDependencies.length) version.pluginDependencies = pluginDependencies; + // Optional author signature over the exact artifact bytes. let authorPublicKey: string | undefined; if (opts.signKeyPath) { diff --git a/plugin-sdk/src/cli/help.ts b/plugin-sdk/src/cli/help.ts new file mode 100644 index 000000000..6f6f98bb6 --- /dev/null +++ b/plugin-sdk/src/cli/help.ts @@ -0,0 +1,241 @@ +/** + * Help, tiered. + * + * The old help was eleven commands in one flat list, one line each, with no order and no + * per-command detail — `trek-plugin dev --help` fell through to the same flat list. So the only + * description of any command was that one line, and nothing anywhere said which to run first. + * + * Two changes. The top-level lists THE PATH (create → dev → status → publish) and pushes the + * other seven to an "Also" line: they are real commands, and an author who knows what `entry` + * is can still reach for it, but nobody has to learn eleven things to publish a plugin. And + * every command now has its own page, with what it does, when you would reach for it, and its + * flags. + */ + +export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`; + +/** The four commands that ARE the job. Everything else is a step one of these already does. */ +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 interface CommandHelp { + /** One line, for the top-level list. */ + summary: string; + /** Usage line. */ + usage: string; + /** What it does and — more usefully — when you would reach for it. */ + body: string; +} + +export const COMMAND_HELP: Record = { + create: { + summary: 'scaffold a new plugin', + usage: 'trek-plugin create [name] [--type integration|page|widget|trip-page] [--template blank|notification-channel]', + body: `Scaffolds a plugin: manifest, server entry, README, and a client UI for anything that is not +an integration. With no name it runs a wizard that asks for the id, type, icon, permissions, +egress hosts and required addons. + +The plugin it gives you RUNS immediately — \`dev\` and \`pack\` work — but it is not publishable +yet: the README is a template and there is no screenshot. That is deliberate, and \`status\` will +tell you exactly what is left. + + --type integration server-only: routes, hooks, background work + page a full navigation page (sandboxed iframe UI) + widget a dashboard widget (sandboxed iframe UI) + trip-page a tab inside every trip (sandboxed iframe UI) + --template blank | notification-channel + --author author name for the manifest + --description one-line description (5-200 chars; the registry requires it) + --permissions "db:own db:read:trips" + --egress api.example.com,api.other.com + --required-addons budget,packing + --interactive force the wizard even when a name is given`, + }, + + dev: { + summary: 'run it locally, hot-reloaded', + usage: 'trek-plugin dev [dir] [--port 4317]', + body: `Runs the plugin against a mock TREK host, with hot reload. It enforces the SAME permissions +your manifest declares, so an ungranted call throws PERMISSION_DENIED here exactly as it would in +production, and \`db:own\` is backed by a real SQLite file under .trek-dev/. + + / a dashboard: your routes, your grants, what is wired up + /preview your UI in the themed, sandboxed frame TREK actually renders it in + /ui your UI raw, unframed + /api/* your routes + /__dev/fire/ fire a job, a scheduled run, an event or a hook by hand + +Binds 127.0.0.1 only. Editing server/ or client/ reloads.`, + }, + + status: { + summary: "where am I? what's left?", + usage: 'trek-plugin status [dir]', + body: `The command to run when you are not sure what to do next. + +Runs every check the TREK-Plugins registry enforces that can be answered without a network — which +is nearly all of them — and prints the whole journey as a checklist: what passes, what does not, +what is still to come, and the ONE command to run next. + +It never fails. It is for orientation, not for gating; \`validate\` is the gate. Run it as often as +you like.`, + }, + + publish: { + summary: 'release + open the registry PR', + usage: 'trek-plugin publish [dir] --repo --tag [--sign]', + body: `The whole release, in order: + + 1. check every registry gate that can be checked locally + 2. pack build plugin.zip + 3. release git tag, push, and cut the GitHub release with the artifact + 4. preflight the gates that need the tag and the release to exist + 5. submit open the PR against the TREK-Plugins registry + +Step 1 comes first for a reason: a GitHub release is effectively immutable, because the registry +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. + +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 +back: once a plugin has shipped signed, TREK refuses an unsigned update to it. So back the key up. +Scripts are never prompted; pass --sign. + +Needs git and an authenticated \`gh\`. + + --sign [key] sign the artifact (default key: ~/.trek-plugin/signing.key) + --registry o/n a registry other than mauriceboe/TREK-Plugins + --notes release notes + --draft open the registry PR as a draft + --force overwrite an existing release's artifact. Breaks the sha256 pin for + everyone who already installed that version — only for a release that + was never merged into the registry. + --no-checks skip step 1. An escape hatch for a re-run; never right on a first publish. + --no-preflight skip step 4.`, + }, + + validate: { + summary: 'check it against the registry gates', + usage: 'trek-plugin validate [dir]', + body: `The gate. Runs the same offline checks as \`status\` and exits non-zero if any of them would +be rejected by the registry. This is the form for scripts and CI; \`status\` is the form for humans. + +A plugin that passes \`validate\` will pass every registry gate that does not require the tag and +the release to exist. Those four run in \`preflight\`.`, + }, + + pack: { + summary: 'build plugin.zip', + usage: 'trek-plugin pack [dir] [--out plugin.zip] [--json]', + body: `Builds the exact plugin.zip the TREK installer expects, and prints its sha256 and byte size. + +It refuses to pack a plugin that could not LOAD — a broken manifest, a missing server entry, a +native binary. It does not enforce the registry's publish gates (an unwritten README, a missing +screenshot), because packing is how you install a plugin into a local TREK to try it, and the docs +only matter when you ship. + + --json the PackResult on stdout, for scripts`, + }, + + shot: { + summary: 'capture docs/screenshot.png', + usage: 'trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]', + body: `Boots the dev server, renders your plugin in the themed frame TREK uses, and writes a 1600×900 +docs/screenshot.png. + +The registry requires a screenshot that resolves to a real image, and the store card shows it. This +is the fastest way to have one. + +Needs Playwright, which is NOT a dependency of this SDK (it ships a browser): + + npm i -D playwright && npx playwright install chromium + +An \`integration\` plugin has no UI to render, so \`shot\` cannot help — screenshot the TREK surface +your plugin changes instead. + + --dark render in TREK's dark theme + --no-serve shoot a dev server you are already running`, + }, + + keygen: { + summary: 'create an Ed25519 signing key', + usage: 'trek-plugin keygen [--key file]', + body: `Generates a signing key at ~/.trek-plugin/signing.key. One key for all your plugins. + +BACK IT UP. Signing is a one-way door: once a plugin has shipped signed, TREK refuses an unsigned +update — and one signed with a different key — on every instance that already has it. Losing the +key means you cannot ship an update to your own plugin without a maintainer override.`, + }, + + sign: { + summary: 'print a signature for an artifact', + usage: 'trek-plugin sign [zip] [--key file]', + body: 'Prints the signature and public key for an artifact. Usually you want `publish --sign` instead.', + }, + + entry: { + summary: 'print the registry entry JSON', + usage: 'trek-plugin entry --repo --tag [--zip plugin.zip] [--merge entry.json] [--out file]', + body: `Builds the TREK-Plugins registry entry from your manifest, your packed artifact and your git tag — +so the sha256, size, commitSha and downloadUrl all come out of one command instead of being +computed by hand. + +\`publish\` does this for you. Reach for it directly only when you are assembling a PR by hand. + + --merge entry.json prepend this version onto an existing entry (the update case) + --sign [key] sign the artifact and pin the author key + --out file write it instead of printing it`, + }, + + preflight: { + summary: 'run the registry CI checks locally', + usage: 'trek-plugin preflight [dir] --repo --tag [--entry file.json] [--all]', + body: `Runs the registry's checks — including the ones that need the network: that your tag resolves to +the commit the entry pins, that the released artifact downloads and hashes to the pinned sha256, +that your plugin id is not bound to another GitHub owner, and that an update does not drop or +rotate a signing key you already published under. + +\`publish\` runs this for you as step 4. Reach for it directly to check an entry you assembled by +hand, or to re-check a release you have already cut. + + --all check every version in the entry, not just the newest`, + }, + + submit: { + summary: 'open the registry PR', + usage: 'trek-plugin submit [dir] --repo --tag [--registry o/n] [--draft]', + body: `Forks the registry, writes registry/plugins/.json, and opens the PR. Needs an authenticated +\`gh\`. \`publish\` does this for you as step 5.`, + }, + + release: { + summary: 'pack + cut the GitHub release', + usage: 'trek-plugin release [dir] --repo --tag [--sign] [--merge entry.json]', + 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.`, + }, +}; + +export function topLevelHelp(version: string): string { + const pad = (s: string) => s.padEnd(9); + const path = PATH_COMMANDS.map((c) => ` ${pad(c)} ${COMMAND_HELP[c].summary}`).join('\n'); + return `${VERSION_LINE(version)} — build a plugin for TREK + + The path +${path} + + Also: ${OTHER_COMMANDS.join(' ')} + \`trek-plugin help \` for any of them + + New here? trek-plugin create + Lost? trek-plugin status`; +} + +export function commandHelp(command: string): string | undefined { + const h = COMMAND_HELP[command]; + if (!h) return undefined; + return `${h.usage}\n\n${h.body}`; +} diff --git a/plugin-sdk/src/cli/menu.ts b/plugin-sdk/src/cli/menu.ts index 4b2ac7941..d60d13743 100644 --- a/plugin-sdk/src/cli/menu.ts +++ b/plugin-sdk/src/cli/menu.ts @@ -12,23 +12,30 @@ export interface MenuItem { hint: string; } -/** The everyday author journey. `advanced`/`exit` are control entries. */ +/** + * The path, in the order you walk it — create, then dev, then status, then publish. The menu is + * often the first thing a new author ever sees, so its ORDER is the only documentation some of + * them will read. `validate` and `pack` moved to Advanced: `status` says everything `validate` + * says and tells you what to do about it, and `publish` packs for you. + */ export const PRIMARY_MENU: MenuItem[] = [ { value: 'create', label: 'Create a plugin', hint: 'Scaffold a new plugin' }, { value: 'dev', label: 'Run the dev server', hint: 'Live-reload your plugin locally' }, - { value: 'validate', label: 'Validate', hint: 'Check the manifest + layout' }, - { value: 'pack', label: 'Pack', hint: 'Build plugin.zip' }, - { value: 'publish', label: 'Publish', hint: 'Release + open the registry PR' }, - { value: 'advanced', label: 'Advanced…', hint: 'Signing, registry entry, preflight' }, + { value: 'status', label: 'Status', hint: "Where am I? What's left before I can publish?" }, + { value: 'shot', label: 'Screenshot', hint: 'Capture docs/screenshot.png (the registry needs one)' }, + { value: 'publish', label: 'Publish', hint: 'Check → release → open the registry PR' }, + { value: 'advanced', label: 'Advanced…', hint: 'Validate, pack, signing, registry entry' }, { value: 'exit', label: 'Exit', hint: '' }, ]; -/** The less-common signing/registry commands, behind the `advanced` entry. */ +/** Real commands, just not ones you need in order to publish a plugin. */ export const ADVANCED_MENU: MenuItem[] = [ + { value: 'validate', label: 'Validate', hint: 'The gate: same checks as status, but it exits non-zero' }, + { value: 'pack', label: 'Pack', hint: 'Build plugin.zip without releasing it' }, { value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' }, { value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' }, { value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' }, - { value: 'preflight', label: 'Preflight', hint: 'Run the registry CI checks locally' }, + { 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: 'back', label: '← Back', hint: '' }, diff --git a/plugin-sdk/src/cli/pack.ts b/plugin-sdk/src/cli/pack.ts index de1371951..7585ddee2 100644 --- a/plugin-sdk/src/cli/pack.ts +++ b/plugin-sdk/src/cli/pack.ts @@ -13,7 +13,9 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import { makeZip, type ZipInput } from '../zip.js'; import { injectTrekUi } from '../ui/kit.js'; -import { validatePluginDir } from './validate.js'; +import { loadContext } from './checks/context.js'; +import { runOffline } from './checks/index.js'; +import { blocking } from './checks/types.js'; const MAX_TOTAL = 50 * 1024 * 1024; const MAX_FILE = 25 * 1024 * 1024; @@ -56,8 +58,18 @@ function walk(base: string, rel: string, out: ZipInput[]): void { } export function packPluginDir(dir: string, outPath: string): PackResult { - const report = validatePluginDir(dir); - if (!report.ok) throw new Error('plugin is not valid:\n - ' + report.errors.join('\n - ')); + // Only the gates that make a plugin UNBUILDABLE — a broken manifest, no server entry, a native + // binary. Not the registry's publish gates: packing is how you install a plugin into a local + // TREK to try it, and refusing to build a zip because the README is still a stub would block + // the dev loop over something that only matters at publish time. `validate`/`status`/`publish` + // apply the full set; see checks/types.ts, CheckBlocks. + const report = blocking(runOffline(loadContext(dir)), 'artifact'); + if (!report.ok) { + throw new Error( + 'plugin is not valid:\n - ' + + report.errors.map((e) => (e.detail ? `${e.title} — ${e.detail}` : e.title)).join('\n - '), + ); + } const outAbs = path.resolve(outPath); const files: ZipInput[] = []; diff --git a/plugin-sdk/src/cli/preflight.ts b/plugin-sdk/src/cli/preflight.ts index db6f4fe9c..0901a9c4f 100644 --- a/plugin-sdk/src/cli/preflight.ts +++ b/plugin-sdk/src/cli/preflight.ts @@ -1,217 +1,54 @@ /** - * trek-plugin preflight — run the registry CI checks locally, over the network, - * BEFORE you open a PR. It mirrors TREK-Plugins' validate-entry.mjs (tag→commit, - * manifest parity incl. operatorEgress/requiredAddons/pluginDependencies, artifact - * sha256/size, author-signature shape + verification, native-binary scan) and - * check-readme.mjs (required sections, real prose, a resolving screenshot, permission - * parity), so you catch what CI would reject without a round-trip through review. + * trek-plugin preflight — run the registry's CI checks locally, BEFORE you open a PR. * - * That mirroring is the whole contract: a gate the registry has and preflight doesn't is - * a false green, and an author trusts a green. Keep the two in step. + * This used to be the ONLY place the registry's real rules were applied, which made it the place + * authors discovered their README was unwritten — over the network, against a pushed tag, after + * an immutable GitHub release had been cut. The rules now live in ./checks/ and most of them fire + * offline from `status` and `validate`, long before any of that. What preflight adds is the last + * mile: the things that can only be known once the tag, the release and the registry exist. * - * Dependency-free: global fetch + Node built-ins + our own zip reader. + * The contract has not changed, and now covers the whole check registry: + * A GATE THE REGISTRY HAS AND WE DON'T IS A FALSE GREEN, and an author trusts a green. + * test/checks-parity.test.ts is what keeps this honest. */ -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { listZipNames } from '../zip.js'; -import { verifyAuthorSignature, checkSignatureShape, SignatureError } from './verify-signature.js'; +import { loadContext } from './checks/context.js'; +import { runAll } from './checks/index.js'; +import type { RegistryEntry } from './checks/types.js'; -const REQUIRED_HEADINGS = ['what it does', 'screenshots', 'permissions', 'setup']; -const PLACEHOLDER_RE = [/\{\{[^}]*\}\}/, /\bREPLACE_ME\b/i, /\bDescribe (what|the)\b/i, /\byour-name\/trek-plugin/i]; -const MIN_PROSE = 400; -const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; +export type { RegistryEntry as Entry, RegistryEntryVersion as EntryVersion } from './checks/types.js'; -export interface PluginDependency { id: string; version: string } -export interface EntryVersion { - version: string; gitTag: string; commitSha: string; downloadUrl: string; - sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string; - requiredAddons?: string[]; pluginDependencies?: PluginDependency[]; -} -export interface Entry { - id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[]; -} -export interface PreflightReport { ok: boolean; failures: string[]; passed: string[]; } - -function ghToken(): string | undefined { - if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; - try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; } +export interface PreflightReport { + ok: boolean; + failures: string[]; + passed: string[]; } -function raw(repo: string, sha: string, p: string): string { - return `https://raw.githubusercontent.com/${repo}/${sha}/${p.replace(/^\.?\//, '')}`; +export interface PreflightOptions { + /** Check every version in the entry, not just the newest. */ + all?: boolean; + /** The plugin directory, for the offline half. Defaults to cwd. */ + dir?: string; + /** Registry repo to check owner binding + signing history against. */ + registry?: string; + /** The exact artifact bytes the entry was built from, when the caller still holds them. */ + zipBytes?: Buffer; } -export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Promise { - const failures: string[] = []; - const passed: string[] = []; - const fail = (m: string) => failures.push(m); - const ok = (m: string) => passed.push(m); +/** Flatten a check outcome into the one-line strings this command has always printed. */ +const line = (o: { title: string; detail?: string; fix?: string }): string => + (o.detail ? `${o.title} — ${o.detail}` : o.title) + (o.fix ? '\n' + o.fix.split('\n').map((l) => ' ' + l).join('\n') : ''); - const token = ghToken(); - const ghHeaders: Record = { 'User-Agent': 'trek-plugin-preflight', Accept: 'application/vnd.github+json' }; - if (token) ghHeaders.Authorization = `Bearer ${token}`; +export async function preflight(entry: RegistryEntry, opts: PreflightOptions = {}): Promise { + const ctx = loadContext(opts.dir ?? '.', { entry, zipBytes: opts.zipBytes }); + ctx.registry = opts.registry; + ctx.allVersions = opts.all; - // Structural checks that mirror the registry schema (fast, offline). - if (!/^[a-z][a-z0-9-]{2,39}$/.test(entry.id)) fail(`id "${entry.id}" is not a valid slug (^[a-z][a-z0-9-]{2,39}$)`); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) fail(`repo "${entry.repo}" is not "owner/name"`); - if (!['integration', 'page', 'widget', 'trip-page'].includes(entry.type)) fail(`type "${entry.type}" is not integration|page|widget|trip-page`); - // Signature SHAPE (mirrors the registry's checkSignatureShape): a key and a signature - // must be both-present or both-absent, and each must parse. TREK refuses to install a - // half-signed entry, so one is dead on arrival — better to say so now than at review. - for (const p of checkSignatureShape(entry)) fail(p); - - const versions = opts.all ? entry.versions : entry.versions.slice(0, 1); - for (const v of versions) { - const tag = `${v.version}`; - // 1. tag exists and resolves to commitSha - try { - const r = await fetch(`https://api.github.com/repos/${entry.repo}/git/refs/tags/${encodeURIComponent(v.gitTag)}`, { headers: ghHeaders }); - if (!r.ok) fail(`${tag}: tag ${v.gitTag} not found on GitHub (${r.status}) — push the tag and create the release`); - else { - const ref = (await r.json()) as { object?: { sha?: string; type?: string } }; - let sha = ref.object?.sha; - if (ref.object?.type === 'tag' && sha) { - const tr = await fetch(`https://api.github.com/repos/${entry.repo}/git/tags/${sha}`, { headers: ghHeaders }); - if (tr.ok) sha = ((await tr.json()) as { object?: { sha?: string } }).object?.sha; - } - if (sha && sha !== v.commitSha) fail(`${tag}: tag ${v.gitTag} points at ${sha.slice(0, 8)}, entry pins ${v.commitSha.slice(0, 8)}`); - else if (sha) ok(`${tag}: tag resolves to the pinned commit`); - } - } catch (e) { fail(`${tag}: tag check failed: ${(e as Error).message}`); } - - // 2. manifest parity at the pinned commit (+ collect permissions for the README gate) - let manifestPerms: string[] = []; - try { - const mr = await fetch(raw(entry.repo, v.commitSha, 'trek-plugin.json'), { headers: { 'User-Agent': 'trek-plugin-preflight' } }); - if (!mr.ok) fail(`${tag}: trek-plugin.json not found at ${v.commitSha.slice(0, 8)} (${mr.status})`); - else { - const m = JSON.parse(await mr.text()) as Record; - if (m.id !== entry.id) fail(`${tag}: manifest id "${m.id}" != entry id "${entry.id}"`); - if (m.version !== v.version) fail(`${tag}: manifest version "${m.version}" != entry "${v.version}"`); - if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`); - // apiVersion is OPTIONAL in the manifest: the host defaults it to 1 at install - // (install/manifest.ts) and `entry` defaults it to 1 when building the entry. Compare - // the DEFAULTED values, or a manifest that legally omits it fails here with - // "manifest apiVersion undefined != entry 1" while the registry's CI passes it. - const mApiVersion = typeof m.apiVersion === 'number' ? m.apiVersion : 1; - if (mApiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${mApiVersion} != entry ${v.apiVersion}`); - if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`); - // Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry - // must not understate the plugin's network reach. - const mOperatorEgress = (m as { operatorEgress?: unknown }).operatorEgress === true; - if (mOperatorEgress !== (v.operatorEgress === true)) { - fail(`${tag}: manifest operatorEgress ${mOperatorEgress} != entry ${v.operatorEgress === true}`); - } - manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : []; - if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { - const egress = Array.isArray(m.egress) ? (m.egress as string[]) : []; - // Empty egress[] is legal only with operatorEgress (hosts are admin-supplied). - if (!egress.length && !mOperatorEgress) { - fail(`${tag}: http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)`); - } - if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`); - } - if (mOperatorEgress && !manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { - fail(`${tag}: operatorEgress declared without an http:outbound permission`); - } - // Dependency parity (mirrors validate-entry.mjs): the enriched entry must mirror the - // manifest, because TREK resolves requiredAddons/pluginDependencies from the INDEX - // before it ever downloads the artifact. An entry that understates them resolves - // against a dependency set the code doesn't actually have. - const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []); - if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) { - fail(`${tag}: manifest requiredAddons != entry requiredAddons`); - } - const normDeps = (d: unknown) => - (Array.isArray(d) ? d.map((x) => `${(x as PluginDependency)?.id}@${(x as PluginDependency)?.version}`).sort() : []); - if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) { - fail(`${tag}: manifest pluginDependencies != entry pluginDependencies`); - } - if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`); - } - } catch (e) { fail(`${tag}: manifest parity failed: ${(e as Error).message}`); } - - // 3. artifact download → sha256 + size + native-binary scan - try { - const dr = await fetch(v.downloadUrl, { redirect: 'follow', headers: { 'User-Agent': 'trek-plugin-preflight' } }); - if (!dr.ok) fail(`${tag}: artifact download failed (${dr.status}) ${v.downloadUrl}`); - else { - const bytes = Buffer.from(await dr.arrayBuffer()); - if (bytes.length > v.size + 4096) fail(`${tag}: artifact (${bytes.length}B) larger than declared size (${v.size}B)`); - const sha = createHash('sha256').update(bytes).digest('hex'); - if (sha !== v.sha256) fail(`${tag}: sha256 mismatch — release has ${sha.slice(0, 12)}…, entry pins ${v.sha256.slice(0, 12)}… (re-pack + re-upload?)`); - else ok(`${tag}: artifact sha256 matches the release`); - - // Author signature over those same bytes. sha256 proves they are what the REGISTRY - // vouches for; the signature proves they came from the AUTHOR's key. The registry's - // CI verifies this and TREK verifies it again at install, so a signature that does - // not check out is an entry nobody can install — and the author would only find out - // at review. We already have the bytes, so it costs nothing to say so now. - if (v.signature && entry.authorPublicKey) { - try { - if (!verifyAuthorSignature(bytes, v.signature, entry.authorPublicKey)) { - fail(`${tag}: author signature does not verify against authorPublicKey — did you sign with a different key, or re-pack after signing?`); - } else ok(`${tag}: author signature verifies`); - } catch (e) { - if (e instanceof SignatureError) fail(`${tag}: signature/key is malformed: ${e.message}`); - else throw e; - } - } - - let names: string[] = []; - try { names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; } catch { /* not a zip */ } - if (names.some((n) => NATIVE_RE.test(n))) fail(`${tag}: artifact contains native binaries (.node / binding.gyp / prebuilds)`); - } - } catch (e) { fail(`${tag}: artifact check failed: ${(e as Error).message}`); } - - // 4. README quality gate at the pinned commit (mirrors check-readme.mjs) - await readmeGate(entry.repo, v.commitSha, manifestPerms, tag, fail, ok); - } - - return { ok: failures.length === 0, failures, passed }; -} - -async function readmeGate(repo: string, sha: string, perms: string[], tag: string, fail: (m: string) => void, ok: (m: string) => void): Promise { - let md: string; - try { - const r = await fetch(raw(repo, sha, 'README.md'), { headers: { 'User-Agent': 'trek-plugin-preflight' } }); - if (!r.ok) { fail(`${tag}: README.md missing at the repo root (${r.status})`); return; } - md = await r.text(); - } catch (e) { fail(`${tag}: README fetch failed: ${(e as Error).message}`); return; } - - const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1].toLowerCase()); - for (const h of REQUIRED_HEADINGS) if (!headings.some((got) => got.includes(h))) fail(`${tag}: README missing required section "## ${h}"`); - for (const re of PLACEHOLDER_RE) { const hit = md.match(re); if (hit) fail(`${tag}: README still has a template placeholder "${hit[0]}"`); } - - const prose = md - .replace(//g, '').replace(/```[\s\S]*?```/g, '') - .replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[[^\]]*\]\([^)]*\)/g, '') - .replace(/^#{1,6}\s+.*$/gm, '').replace(/^\s*\|.*$/gm, '') - .replace(/[#>*_`|-]/g, '').replace(/\s+/g, ' ').trim(); - if (prose.length < MIN_PROSE) fail(`${tag}: README has too little prose (${prose.length} chars, need ≥ ${MIN_PROSE})`); - - const imgs = [ - ...[...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]), - ...[...md.matchAll(/]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]), - ].filter((u) => !u.startsWith('data:')); - if (!imgs.length) fail(`${tag}: README has no screenshot (at least one image required)`); - else { - let anyOk = false; - for (const src of [...new Set(imgs)]) { - const url = /^https?:\/\//.test(src) - ? (src.includes('github.com') && src.includes('/blob/') ? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') : src) - : raw(repo, sha, src); - try { - const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } }); - if (r.ok && (r.headers.get('content-type') || '').startsWith('image/')) { anyOk = true; break; } - } catch { /* try next */ } - } - if (!anyOk) fail(`${tag}: no README screenshot resolved to a real image at the pinned commit`); - else ok(`${tag}: README screenshot resolves`); - } - - const lower = md.toLowerCase(); - const undocumented = perms.filter((p) => !lower.includes(p.toLowerCase())); - if (undocumented.length) fail(`${tag}: permissions not explained in the README: ${undocumented.join(', ')}`); - else if (perms.length) ok(`${tag}: all permissions documented`); + const report = await runAll(ctx); + return { + ok: report.ok, + // Warnings are surfaced by `status`/`validate`; preflight reports only what would REJECT the + // entry, because its whole promise is "this will get through CI". + failures: report.errors.map(line), + passed: report.outcomes.filter((o) => o.status === 'pass').map((o) => (o.detail ? `${o.title} — ${o.detail}` : o.title)), + }; } diff --git a/plugin-sdk/src/cli/publish.ts b/plugin-sdk/src/cli/publish.ts index e72eab4af..3632c8b38 100644 --- a/plugin-sdk/src/cli/publish.ts +++ b/plugin-sdk/src/cli/publish.ts @@ -1,9 +1,18 @@ /** - * trek-plugin publish — the whole release in one command: pack → tag + GitHub - * release → preflight (the registry CI checks, locally) → open the registry PR. - * If preflight fails it stops before submitting, so a broken entry never becomes - * a doomed PR. This is the short path; the individual commands still exist for - * when you want a step by hand. + * trek-plugin publish — the whole release in one command: + * check → pack → tag + GitHub release → preflight → open the registry PR. + * + * THE ORDER IS THE POINT. It used to be pack → release → preflight → submit, which meant the + * checks that catch an unwritten README or a missing screenshot ran AFTER the GitHub release had + * been cut. A release is immutable in every way that matters — the registry pins its sha256, so + * overwriting the bytes breaks the checksum for everyone who already installed it — and so an + * author who failed preflight was stuck: their v1.0.0 tag was burned, and the fix was to throw it + * away and cut a v1.0.1 whose only change was the README. + * + * Now step 1 runs every gate that can be answered from the working tree, and NOTHING is packed, + * tagged, pushed or released until they all pass. Preflight still runs (step 4) because the last + * few gates genuinely need the tag and the release to exist — but by then it is checking things + * that are true by construction, not discovering that the README was never written. * * Requires `git` + `gh` (authenticated), same as `release`/`submit`. */ @@ -13,6 +22,10 @@ import { packPluginDir } from './pack.js'; import { buildEntry } from './entry.js'; import { preflight } from './preflight.js'; import { submitEntry } from './submit.js'; +import { loadContext } from './checks/context.js'; +import { runOffline } from './checks/index.js'; +import { renderPlain } from './checks/report.js'; +import { assertSigningAllowed, inspectSigning, type SigningState } from './signing.js'; import { plainLog, type LogSink } from './ui.js'; function git(dir: string, args: string[], quiet = true): string { @@ -30,30 +43,63 @@ export async function publishPlugin(opts: { signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean; /** Overwrite the artifact on an existing release. Only safe if it was never merged. */ force?: boolean; + /** Skip the local gates. An escape hatch for a re-run, never a good idea on a first publish. */ + skipChecks?: 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. + */ + signing?: SigningState; /** Progress sink. Defaults to the plain console.error lines (CI parity). */ log?: LogSink; }): Promise<{ prUrl: string }> { const dir = path.resolve(opts.dir); const log = opts.log ?? plainLog; - const step = (n: number, msg: string) => log(`[${n}/4] ${msg}`); + const step = (n: number, msg: string) => log(`[${n}/5] ${msg}`); - // 1. Pack - step(1, 'Packing the artifact…'); + // 1. The local gates — BEFORE anything is packed, tagged or released. + if (opts.skipChecks) { + log('[1/5] Local checks skipped (--no-checks).'); + } else { + step(1, 'Checking the plugin against the registry gates…'); + const ctx = loadContext(dir); + const report = runOffline(ctx); + if (!report.ok) { + const detail = renderPlain({ ...report, warnings: [] }); + throw new Error( + `${report.errors.length} check${report.errors.length === 1 ? '' : 's'} would be rejected by the registry.\n\n` + + detail + + '\n\nNothing was packed, tagged or released. Fix these and re-run — or `trek-plugin status` for the full picture.', + ); + } + + // Signing is not just a preference once a plugin has shipped signed: TREK refuses an unsigned + // update to it, so this publish is doomed. preflight would catch it — at step 4, AFTER the + // immutable release is cut, which is precisely the trap this command's reorder exists to close. + // So it belongs here, before anything exists to be wasted. + const id = typeof ctx.manifest?.id === 'string' ? ctx.manifest.id : ''; + if (id) { + assertSigningAllowed(opts.signing ?? (await inspectSigning(id, { registry: opts.registry })), opts.signKeyPath); + } + + log(` ✓ ${report.outcomes.filter((o) => o.status === 'pass').length} checks passed`); + } + + // 2. Pack + step(2, 'Packing the artifact…'); const zip = path.join(dir, 'plugin.zip'); const packed = packPluginDir(dir, zip); log(` ✓ ${packed.files.length} files, ${packed.size} bytes`); - // 2. Tag (if needed) + push + GitHub release with the artifact attached - step(2, `Tagging ${opts.tag} + creating the GitHub release…`); + // 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 { 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. - // (The old code blanket-caught every `gh release create` failure — auth, network, a bad - // repo — and turned it into a --clobber upload.) + // 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 (!opts.force) { throw new Error( @@ -69,25 +115,31 @@ export async function publishPlugin(opts: { } log(` ✓ release ${opts.tag} on ${opts.repo}`); - // 3. Build the entry, then run the registry CI checks locally + // 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('[3/4] Preflight skipped (--no-preflight).'); + log('[4/5] Preflight skipped (--no-preflight).'); } else { - step(3, 'Preflight — running the registry CI checks…'); - const rep = await preflight(entry); + 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) — fix these and re-run (nothing was submitted). Did you push your code to ${opts.repo} before publishing?`); + if (!rep.ok) { + 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?`, + ); + } log(` ✓ all ${rep.passed.length} checks passed`); } - // 4. Open the registry PR - step(4, 'Opening the registry PR…'); + // 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. + // 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 }; } diff --git a/plugin-sdk/src/cli/shot.ts b/plugin-sdk/src/cli/shot.ts new file mode 100644 index 000000000..0488bee82 --- /dev/null +++ b/plugin-sdk/src/cli/shot.ts @@ -0,0 +1,176 @@ +/** + * trek-plugin shot — capture docs/screenshot.png. + * + * The screenshot is the one registry gate an author cannot fake, cannot satisfy by writing more + * words, and — until now — could not do anything about from the CLI at all. The scaffold's README + * linked `./docs/screenshot.png`, nothing created it, `validate` passed anyway (it only looked for + * an image LINK), and the truth arrived from CI after the release was immutable. + * + * So: boot the dev server, render the plugin in the same themed, sandboxed frame TREK uses + * (`/preview`), and write a 1600×900 PNG. One command, and the hardest gate is done. + * + * Playwright is NOT a dependency of this SDK — it is ~300 MB of browser and most authors never + * run this. It is imported lazily, and if it is absent we say exactly how to get it rather than + * failing with a module-resolution stack trace. + */ +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { readJsonFile } from './json.js'; + +/** The store card is 16:9 and crops the edges — 1600×900 leaves margin and stays under a MB. */ +const WIDTH = 1600; +const HEIGHT = 900; + +export interface ShotOptions { + port?: number; + out?: string; + /** Screenshot an already-running dev server instead of booting one. */ + noServe?: boolean; + /** Render in TREK's dark theme instead of light. */ + dark?: boolean; +} + +export class ShotError extends Error {} + +const portOpen = (port: number): Promise => + new Promise((resolve) => { + const socket = net.connect(port, '127.0.0.1'); + socket.on('connect', () => { socket.destroy(); resolve(true); }); + socket.on('error', () => resolve(false)); + }); + +async function waitForPort(port: number, timeoutMs = 30_000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await portOpen(port)) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +/** + * Playwright, or a message that tells you how to get it. + * + * Resolved from the PLUGIN's directory first, not ours. A bare `import('playwright')` resolves + * relative to this file — which works when the SDK is a devDependency of the plugin (node walks up + * into the plugin's node_modules), and silently does not when the SDK is installed globally. The + * author would have run exactly the `npm i -D playwright` we told them to and still be told it was + * missing. So ask the plugin where its own modules live, and only then fall back to ours. + */ +async function loadChromium(pluginDir: string): Promise<{ launch: (o?: unknown) => Promise }> { + type PW = { chromium: { launch: (o?: unknown) => Promise } }; + + const fromPlugin = createRequire(path.join(pluginDir, 'package.json')); + for (const resolve of [() => fromPlugin.resolve('playwright'), () => 'playwright']) { + try { + const specifier = resolve(); + const pw = (await import(specifier.startsWith('/') ? pathToFileURL(specifier).href : specifier)) as unknown as PW; + if (pw.chromium) return pw.chromium; + } catch { + /* try the next resolution */ + } + } + + throw new ShotError( + 'trek-plugin shot needs Playwright, which is not installed.\n\n' + + ' npm i -D playwright && npx playwright install chromium\n\n' + + 'It is not a dependency of the SDK — it ships a whole browser, and most authors never run this.\n' + + 'If you would rather not install it: take a 16:9 screenshot yourself (1600×900 is ideal — the\n' + + 'store card crops the edges) and save it as docs/screenshot.png.', + ); +} + +// Minimal structural types — we never import Playwright's, so it stays a soft dependency. +interface Page { + goto(url: string, o?: unknown): Promise; + emulateMedia(o: { colorScheme: 'light' | 'dark' }): Promise; + waitForTimeout(ms: number): Promise; + screenshot(o: { path: string; fullPage?: boolean }): Promise; +} +interface Browser { + newPage(o?: unknown): Promise; + close(): Promise; +} +type Chromium = Browser; + +export async function runShot(dir: string, opts: ShotOptions = {}): Promise<{ out: string; width: number; height: number }> { + const root = path.resolve(dir); + const manifestPath = path.join(root, 'trek-plugin.json'); + if (!fs.existsSync(manifestPath)) throw new ShotError(`no trek-plugin.json in ${dir} — is this a plugin directory?`); + + const manifest = readJsonFile<{ type?: string; id?: string }>(manifestPath); + const type = String(manifest.type ?? ''); + + // An integration plugin has no UI to render. Refusing outright would be unhelpful — it still + // NEEDS a screenshot, it just isn't one we can take. Say what to shoot instead. + if (type === 'integration') { + throw new ShotError( + 'An integration plugin has no UI, so there is nothing for the dev server to render.\n\n' + + 'It still needs a screenshot — the registry requires one and the store card shows it. Capture the\n' + + 'TREK surface your plugin CHANGES: the notification it sends, the badge it adds to a place, the row\n' + + 'it contributes to a table, its settings page under Admin → Plugins.\n\n' + + 'Save it as docs/screenshot.png (16:9, 1600×900 is ideal — the card crops the edges).', + ); + } + + const port = opts.port ?? 4317; + const out = path.resolve(root, opts.out ?? path.join('docs', 'screenshot.png')); + const chromium = await loadChromium(root); + + let dev: ChildProcess | undefined; + let browser: Browser | undefined; + try { + if (!opts.noServe) { + if (await portOpen(port)) { + throw new ShotError( + `port ${port} is already in use.\n` + + `If that is your dev server, pass --no-serve to shoot it. Otherwise pick another port with --port.`, + ); + } + // Reuse the real dev server rather than reimplementing the preview frame — a screenshot of + // something that isn't what `dev` serves would be worse than no screenshot at all. + // + // Spawn the CLI, not dev.js: dev.js only EXPORTS runDev, it has no entry block, so running + // it as a script imports a module and exits — and the only symptom is a 30-second wait. + dev = spawn(process.execPath, [path.join(import.meta.dirname, 'trek-plugin.js'), 'dev', root, '--port', String(port)], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + // Keep the child's stderr. If it dies (a plugin that throws on load, a port clash), that + // message is the whole diagnosis — swallowing it leaves the author staring at a timeout. + let devErr = ''; + dev.stderr?.on('data', (b: Buffer) => { devErr += b.toString(); }); + + if (!(await waitForPort(port))) { + throw new ShotError( + `the dev server did not come up on port ${port} within 30s.` + + (devErr.trim() ? `\n\n${devErr.trim()}` : '\n\nRun `trek-plugin dev` yourself to see why.'), + ); + } + } else if (!(await portOpen(port))) { + throw new ShotError(`--no-serve was passed but nothing is listening on port ${port}`); + } + + browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: 2 }); + await page.emulateMedia({ colorScheme: opts.dark ? 'dark' : 'light' }); + // chrome=0 strips the dev toolbar and the "runs sandboxed at an opaque origin" note, and + // centres the plugin. Without it the store card would show the theme picker of the tool that + // took the screenshot, which is not a picture of anybody's plugin. + const theme = opts.dark ? 'dark' : 'light'; + await page.goto(`http://localhost:${port}/preview?chrome=0&theme=${theme}`, { waitUntil: 'networkidle' }); + // The plugin's own UI loads inside a sandboxed iframe and then fetches. Give it a beat, or we + // photograph a skeleton — a screenshot of a loading spinner passes every gate and helps nobody. + await page.waitForTimeout(1200); + + fs.mkdirSync(path.dirname(out), { recursive: true }); + await page.screenshot({ path: out }); + return { out, width: WIDTH, height: HEIGHT }; + } finally { + await browser?.close().catch(() => {}); + dev?.kill(); + } +} diff --git a/plugin-sdk/src/cli/signing.ts b/plugin-sdk/src/cli/signing.ts new file mode 100644 index 000000000..9a3457b5b --- /dev/null +++ b/plugin-sdk/src/cli/signing.ts @@ -0,0 +1,147 @@ +/** + * Signing, proposed rather than buried. + * + * `keygen` lived under "Advanced…" in the menu and `--sign` was a flag you had to already know + * about, so the default path — the one almost everyone takes — shipped unsigned, and nothing + * anywhere mentioned that signing existed. That is the wrong default to arrive at by accident, + * because of what signing actually means (server registry.service.ts, verifySignatureAndTofu): + * + * - Unsigned throughout is fine. The sha256 pin is the only guarantee, and TREK accepts it. + * - UNSIGNED → SIGNED later is allowed. Nothing is pinned until a signed version installs, so + * adding a key at v1.4.0 breaks nobody. Signing late is a real option, not a lost cause. + * - SIGNED → UNSIGNED is refused forever (SIGNATURE_MISSING) on every instance that already has + * the plugin. And rotating the key needs a registry maintainer override AND an admin re-trust + * on every instance. + * + * So it is a one-way door you may walk through late, but never back out of. That makes the right + * prompt an honest recommendation with the consequence attached — not a scare, and not silence. + * + * The other half of this file is the guard. If a plugin was published signed and you publish + * unsigned, the registry refuses it — and `preflight` caught that at step 4, AFTER the immutable + * GitHub release was cut. Same trap as the one `publish`'s reorder closed. It belongs in step 1. + */ +import fs from 'node:fs'; +import { defaultKeyPath, generateKeypair } from './sign.js'; +import { DEFAULT_REGISTRY } from './checks/network.js'; +import { isInteractive, promptConfirm, note, logWarn, logSuccess } from './ui.js'; + +export interface SigningState { + /** A signing key exists on this machine. */ + hasKey: boolean; + keyPath: string; + /** This plugin is ALREADY PUBLISHED SIGNED — TREK refuses an unsigned update to it. */ + publishedSigned: boolean; + /** The key it was published under, when publishedSigned. */ + publishedKey?: string; +} + +/** + * What we know about this plugin's signing situation. + * + * The registry lookup is best-effort: offline, or a registry we cannot read, yields + * `publishedSigned: false`. That is the safe direction — we may fail to *insist* on signing, and + * preflight still catches it before the PR — but we never wrongly insist on it, which would block + * an author who has done nothing wrong. + */ +export async function inspectSigning( + pluginId: string, + opts: { registry?: string; keyPath?: string } = {}, +): Promise { + const keyPath = opts.keyPath ?? defaultKeyPath(); + const state: SigningState = { hasKey: fs.existsSync(keyPath), keyPath, publishedSigned: false }; + + const registry = opts.registry ?? DEFAULT_REGISTRY; + try { + const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/registry/plugins/${pluginId}.json`, { + headers: { 'User-Agent': 'trek-plugin' }, + }); + if (!r.ok) return state; // not published yet — nothing to be bound by + const published = (await r.json()) as { authorPublicKey?: string }; + if (published.authorPublicKey) { + state.publishedSigned = true; + state.publishedKey = published.authorPublicKey; + } + } catch { + /* offline, or an unreadable registry — see the doc comment */ + } + return state; +} + +/** + * The guard: refuse to start a publish that is going to be rejected for a signing reason. + * + * Runs BEFORE anything is packed, tagged or released. Getting this wrong is expensive in a way + * most check failures are not: the release is immutable, so an author who learns at step 4 that + * their update had to be signed has burned the tag. + */ +export function assertSigningAllowed(state: SigningState, signKeyPath: string | undefined): void { + if (!state.publishedSigned || signKeyPath) return; + + throw new Error( + 'this plugin was published SIGNED, so this update must be signed too.\n\n' + + 'TREK pins the author key on first install. An unsigned update to a signed plugin is REFUSED on every\n' + + 'instance that already has it — publishing this would strand every existing user on the version they have.\n\n' + + (state.hasKey + ? `Re-run with --sign (your key is at ${state.keyPath}).` + : `No signing key found at ${state.keyPath}.\n` + + 'If you have a backup of the key you published with, restore it there. If you have LOST it, the key\n' + + 'cannot be rotated without a registry maintainer override (the `allow-key-change` label).'), + ); +} + +/** + * Ask, in a terminal, whether to sign — and create the key if there isn't one. + * + * Returns the key path to sign with, or undefined to publish unsigned. Only ever called + * interactively: a script or CI run that did not pass --sign gets exactly the behaviour it always + * got, because prompting a pipeline is how you hang a pipeline. + */ +export async function proposeSigning(state: SigningState): Promise { + if (!isInteractive()) return undefined; + + // Already published signed: this is not a choice, and pretending it is would be a lie. Say so + // and sign. (assertSigningAllowed handles the no-key case with a much longer explanation.) + if (state.publishedSigned) { + if (!state.hasKey) return undefined; // let the guard produce the real error + logWarn('This plugin was published signed — TREK refuses an unsigned update, so this release will be signed.'); + return state.keyPath; + } + + if (state.hasKey) { + const sign = await promptConfirm({ + message: `Sign this release with your key? (${state.keyPath})`, + initialValue: true, + }); + return sign ? state.keyPath : undefined; + } + + note( + 'A signature proves the artifact came from YOU, not just that its bytes match what the registry saw.\n' + + 'TREK pins your key the first time someone installs a signed version, and refuses anything not signed\n' + + 'with it after that.\n\n' + + 'It is a one-way door — but you can walk through it late. Publishing unsigned now and signing at v1.4.0\n' + + 'breaks nobody. Publishing SIGNED and then unsigned breaks everybody.\n\n' + + 'If you sign: back the key up. Losing it means you cannot ship an update to your own plugin without a\n' + + 'registry maintainer override.', + 'Signing', + ); + + const create = await promptConfirm({ + message: 'Create a signing key and sign this release? (recommended)', + initialValue: true, + }); + if (!create) { + logWarn('Publishing unsigned. You can add signing later with `trek-plugin keygen`, and it will break nobody.'); + return undefined; + } + + const { publicKey } = generateKeypair(state.keyPath); + logSuccess(`Signing key written to ${state.keyPath}`); + note( + `${publicKey}\n\n` + + 'This is your authorPublicKey — it goes into the registry entry, and `publish` puts it there for you.\n' + + 'BACK UP THE PRIVATE KEY. It is the only thing that can sign an update to this plugin.', + 'Back this up', + ); + return state.keyPath; +} diff --git a/plugin-sdk/src/cli/status.ts b/plugin-sdk/src/cli/status.ts new file mode 100644 index 000000000..3eaa465b1 --- /dev/null +++ b/plugin-sdk/src/cli/status.ts @@ -0,0 +1,87 @@ +/** + * trek-plugin status [dir] — where am I, and what do I do next? + * + * The command an author runs when they are lost, which — before this existed — was most of the + * time. It runs every offline gate the registry enforces, prints the whole journey as a + * checklist, and names ONE next command. + * + * It deliberately never exits non-zero. `status` is for orientation; `validate` is the gate. A + * command you are afraid to run because it might fail your build is not a command you reach for + * when you are stuck. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { loadContext } from './checks/context.js'; +import { runOffline } from './checks/index.js'; +import { renderChecklist, nextStep, summaryLine } from './checks/report.js'; + +/** Has this version already been tagged locally? Cheap, offline — no fetch, no API. */ +function isTagged(dir: string, version: string): boolean { + for (const tag of [`v${version}`, version]) { + try { + execFileSync('git', ['-C', dir, 'rev-parse', `${tag}^{commit}`], { stdio: 'pipe' }); + return true; + } catch { + /* not this one */ + } + } + return false; +} + +/** Are there uncommitted changes? The registry grades the COMMIT, so a dirty tree is worth saying. */ +function isDirty(dir: string): boolean { + try { + return execFileSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().length > 0; + } catch { + return false; // not a git repo — `publish` will say so far more usefully than we can here + } +} + +export interface StatusOptions { + /** Show passing checks too, not just problems. Defaults to true — the whole point is the map. */ + verbose?: boolean; + colour?: boolean; +} + +export function runStatus(dir: string, opts: StatusOptions = {}): { ok: boolean; text: string } { + const root = path.resolve(dir); + const out: string[] = []; + + if (!fs.existsSync(path.join(root, 'trek-plugin.json'))) { + return { + ok: false, + text: + ` No plugin here.\n\n ${path.relative(process.cwd(), root) || '.'} has no trek-plugin.json.\n\n` + + ' next → trek-plugin create', + }; + } + + const ctx = loadContext(root); + const report = runOffline(ctx); + + const id = typeof ctx.manifest?.id === 'string' ? ctx.manifest.id : path.basename(root); + const type = typeof ctx.manifest?.type === 'string' ? ctx.manifest.type : '?'; + const version = typeof ctx.manifest?.version === 'string' ? ctx.manifest.version : '?'; + + out.push(''); + out.push(` ${id} · ${type} · v${version}`); + out.push(renderChecklist(report, { verbose: opts.verbose ?? true, colour: opts.colour })); + + // Git state is not a "gate" — it is context. A plugin can be perfectly valid and simply not + // released yet, and calling that a failure would be a lie. + out.push(''); + out.push(' Repo'); + out.push(' ' + (isTagged(root, version) ? `· v${version} is tagged locally` : `· v${version} is not tagged yet`)); + if (isDirty(root)) { + out.push(' ! uncommitted changes — the registry grades the COMMIT, not your working tree'); + } + + const next = nextStep(report); + out.push(''); + out.push(' ' + summaryLine(report)); + out.push(next.command ? ` next → ${next.command}` : ` next → ${next.hint}`); + out.push(''); + + return { ok: report.ok, text: out.join('\n') }; +} diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index ea4c1ae56..971b420ff 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -2,38 +2,33 @@ /** * `trek-plugin ` — the plugin author CLI (#plugins). * - * create [name] [--type t] [--interactive] scaffold a plugin (wizard if no name) - * [--template blank|notification-channel] type: integration|page|widget|trip-page - * [--egress host,host] [--required-addons a,b] - * dev [dir] [--port 4317] run locally with hot reload - * validate [dir] check the manifest + layout - * pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size - * keygen [--key file] create an Ed25519 signing key - * sign [zip] [--key file] print a signature + public key for an artifact - * entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry - * [--merge entry.json] [--sign [key]] [--out f] - * preflight [dir] --repo o/n --tag vX run the registry CI checks locally - * submit [dir] --repo o/n --tag vX open the registry PR for you - * [--sign [key]] [--registry o/n] [--draft] - * release [dir] --repo o/n --tag vX pack -> gh release -> print entry - * [--sign [key]] [--merge entry.json] - * publish [dir] --repo o/n --tag vX the lot: pack -> tag+release -> - * [--sign [key]] [--no-preflight] preflight -> open the registry PR + * This file is the ROUTER, and nothing more: it parses flags, decides interactive vs not, and + * hands off. The per-command help lives in ./help.ts (single source of truth, so `--help` and the + * docs cannot drift), and the rules every command is judged by live in ./checks/. * - * The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha - * or hand-write the registry JSON. + * The path is four commands — create → dev → status → publish. The other nine are steps one of + * those already does, and are listed under "Also" rather than presented as things you must learn. + * + * The goal: never hand-compute sha256/size/commitSha, never hand-write the registry JSON, and + * never discover a problem after the release that pins it has been cut. */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { validatePluginDir } from './validate.js'; import { packPluginDir } from './pack.js'; import { buildEntry } from './entry.js'; -import { scaffold, interactiveScaffold } from './create.js'; +import { scaffold, interactiveScaffold, nextStepsAfterCreate, type WidgetSlot } from './create.js'; import { runDev } from './dev.js'; +import { runStatus } from './status.js'; +import { runShot } from './shot.js'; import { preflight } from './preflight.js'; import { submitEntry } from './submit.js'; import { publishPlugin } from './publish.js'; +import { loadContext } from './checks/context.js'; +import { runOffline } from './checks/index.js'; +import { renderPlain } from './checks/report.js'; +import { topLevelHelp, commandHelp, VERSION_LINE } from './help.js'; +import { inspectSigning, proposeSigning, type SigningState } from './signing.js'; import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; import { readJsonFile } from './json.js'; import { @@ -74,17 +69,19 @@ type Flags = Record; * instruction is worse than refusing it, so unknown flags are now an error. */ const COMMAND_FLAGS: Record = { - create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons'], + create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons', 'icon', 'slot'], dev: ['port'], + status: [], + shot: ['port', 'out', 'dark', 'no-serve'], validate: [], pack: ['out', 'json'], keygen: ['key'], sign: ['key'], entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out'], - preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all'], + 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', 'force'], + publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force'], }; function assertKnownFlags(command: string, f: Flags): void { @@ -129,56 +126,99 @@ async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string; return { repo: repo.trim(), tag: tag.trim() }; } -const USAGE = 'usage: trek-plugin [...]'; +const USAGE = 'usage: trek-plugin (`trek-plugin help` for the full list)'; -const HELP = `${USAGE} - - create [name] [--type integration|page|widget|trip-page] scaffold a plugin (wizard if no name) - [--template blank|notification-channel] [--interactive] - [--author a] [--description d] [--permissions "a b"] - [--egress host,host] [--required-addons a,b] - dev [dir] [--port 4317] run locally with hot reload - validate [dir] check the manifest + layout - pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size - keygen [--key file] create an Ed25519 signing key - sign [zip] [--key file] print a signature + public key for an artifact - entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry - [--dir d] [--commit c] [--asset a] [--merge entry.json] [--sign [key]] [--out f] - preflight [dir] --repo o/n --tag vX run the registry CI checks locally - [--entry e] [--zip z] [--commit c] [--sign [key]] [--all] - submit [dir] --repo o/n --tag vX open the registry PR for you - [--sign [key]] [--registry o/n] [--branch b] [--draft] [--keep] - release [dir] --repo o/n --tag vX pack -> gh release -> print entry - [--out f] [--notes n] [--sign [key]] [--merge entry.json] - publish [dir] --repo o/n --tag vX the lot: pack -> tag+release -> - [--sign [key]] [--no-preflight] preflight -> open the registry PR - [--registry o/n] [--notes n] [--draft] - [--force] overwrite an existing release's artifact - (breaks the sha256 pin if it was ever merged) - -The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha -or hand-write the registry JSON.`; +/** The SDK's own version, for `--version` and the help banner. */ +function sdkVersion(): string { + try { + const pkg = readJsonFile<{ version?: string }>(path.join(import.meta.dirname, '..', '..', 'package.json')); + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} async function main(): Promise { // Advisory only (update-notifier): prints from cache on stderr, refreshes in the // background, never blocks. `dev` never returns, so this runs up front. notifySdkUpdate(); - // Before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown. - // Help is not an error: it goes to stdout and exits 0. - if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flags.help) { - console.log(HELP); + + // `--version` used to fall through to the `else` branch and print the usage line with exit 2, + // which is a strange way to answer a question every CLI is expected to answer. + if (cmd === 'version' || cmd === '--version' || cmd === '-v') { + console.log(VERSION_LINE(sdkVersion())); return; } + + // Handled before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown. + // Help is not an error: it goes to stdout and exits 0. + // trek-plugin help the path, plus where to find everything else + // trek-plugin help that command's own page + // trek-plugin --help the same page — this is what people actually type + if (cmd === 'help' || cmd === '--help' || cmd === '-h') { + const topic = pos[0]; + if (topic) { + const page = commandHelp(topic); + if (!page) fail(`no such command: ${topic}\n ${USAGE}`); + console.log(page); + return; + } + console.log(topLevelHelp(sdkVersion())); + return; + } + if (cmd && flags.help) { + const page = commandHelp(cmd); + if (!page) fail(`no such command: ${cmd}\n ${USAGE}`); + console.log(page); + return; + } + if (!cmd) { // Bare invocation: a menu in a terminal, the usage line for scripts. if (!isInteractive()) { console.error(USAGE); process.exit(2); } const chosen = await runMenu(); - if (chosen) await dispatch(chosen, {}, []); + if (chosen) { + // The menu used to dispatch with EMPTY positionals, so picking "Validate" or "Pack" + // silently ran against the cwd — which is very often not a plugin at all. Ask. + const dir = await promptPluginDir(chosen); + await dispatch(chosen, {}, dir ? [dir] : []); + } return; } await dispatch(cmd, flags, pos); } +/** The plugin's id, or '' if this isn't a plugin directory. Never throws — the caller has better errors. */ +function readPluginId(dir: string): string { + try { + const m = readJsonFile<{ id?: unknown }>(path.join(path.resolve(dir), 'trek-plugin.json')); + return typeof m.id === 'string' ? m.id : ''; + } catch { + return ''; + } +} + +/** Commands that operate on a plugin directory, and so need one when the menu launched them. */ +const DIR_COMMANDS = new Set(['dev', 'status', 'validate', 'pack', 'shot', 'publish', 'preflight', 'submit', 'release']); + +async function promptPluginDir(command: string): Promise { + if (!DIR_COMMANDS.has(command)) return undefined; + // If the cwd IS a plugin, that is overwhelmingly what was meant — don't make them type a dot. + if (fs.existsSync(path.join(process.cwd(), 'trek-plugin.json'))) return undefined; + const dir = await promptText({ + message: 'Which plugin directory?', + placeholder: './my-plugin', + defaultValue: '.', + validate: (v) => { + const d = (v || '.').trim(); + if (!fs.existsSync(d)) return `${d} does not exist`; + if (!fs.existsSync(path.join(d, 'trek-plugin.json'))) return `${d} has no trek-plugin.json`; + return undefined; + }, + }); + return dir.trim() || '.'; +} + async function dispatch(command: string, f: Flags, positional: string[]): Promise { const tui = isInteractive(); assertKnownFlags(command, f); @@ -195,22 +235,47 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis egress: listFlag(f.egress), requiredAddons: listFlag(f['required-addons']), template: f.template as 'blank' | 'notification-channel' | undefined, + icon: f.icon, + slot: f.slot as WidgetSlot | undefined, }); - console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`); + // The SAME text the wizard and the `create-trek-plugin` bin print. These three used to + // name three different next commands. + console.log(nextStepsAfterCreate(path.join(process.cwd(), name))); } else if (command === 'dev') { if (tui) intro(`trek-plugin dev — ${positional[0] || '.'}`); await runDev(positional[0] || '.', { port: f.port ? Number(f.port) : undefined }); - } else if (command === 'validate') { - const r = validatePluginDir(positional[0] || '.'); + } else if (command === 'status') { + // Never exits non-zero: `status` is for orientation, not gating. A command you are afraid + // to run because it might fail your build is not one you reach for when you are stuck. + const r = runStatus(positional[0] || '.', { colour: tui }); + console.log(r.text); + } else if (command === 'shot') { + const dir = positional[0] || '.'; if (tui) { - for (const w of r.warnings) logWarn(w); - if (!r.ok) { for (const e of r.errors) logError(e); outro('✗ plugin has errors'); process.exit(1); } - outro('✓ plugin is valid'); + const s = spinner(); + s.start('Rendering your plugin'); + try { + const r = await runShot(dir, { port: f.port ? Number(f.port) : undefined, out: f.out, dark: !!f.dark, noServe: !!f['no-serve'] }); + s.stop(`Wrote ${path.relative(process.cwd(), r.out) || r.out} (${r.width}×${r.height})`); + outro('next → trek-plugin status'); + } catch (e) { + s.stop('Could not take the screenshot'); + throw e; + } } else { - for (const w of r.warnings) console.warn('warning: ' + w); - if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } - console.log('✓ plugin is valid'); + const r = await runShot(dir, { port: f.port ? Number(f.port) : undefined, out: f.out, dark: !!f.dark, noServe: !!f['no-serve'] }); + console.log(`Wrote ${r.out} (${r.width}×${r.height})`); } + } else if (command === 'validate') { + const report = runOffline(loadContext(positional[0] || '.')); + const text = renderPlain(report); + if (text) console.error(text); + if (!report.ok) { + // Don't just fail — say where to see the whole picture. + console.error(`\n${report.errors.length} of these would be rejected by the registry. \`trek-plugin status\` shows the full checklist.`); + process.exit(1); + } + if (tui) outro('✓ plugin is valid'); else console.log('✓ plugin is valid'); } else if (command === 'pack') { const r = packPluginDir(positional[0] || '.', f.out || 'plugin.zip'); const rel = path.relative(process.cwd(), r.artifact) || r.artifact; @@ -218,23 +283,25 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis console.log(JSON.stringify(r, null, 2)); // machine output — never decorated } else if (tui) { note([...r.files, '', `sha256: ${r.sha256}`, `size: ${r.size}`].join('\n'), `Packed ${r.files.length} files → ${rel}`); - logInfo('Upload plugin.zip to your release, then run `npx trek-plugin-sdk entry`.'); + // This used to point at `entry` — the low-level, assemble-the-PR-by-hand path — rather than + // at `publish`, which does the release, the entry and the PR and gets the ORDER right. + logInfo('Install this into a local TREK to try it. To ship it: `trek-plugin publish`.'); } else { console.log(`Packed ${r.files.length} files -> ${rel}`); for (const file of r.files) console.log(' ' + file); console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`); - console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.'); + console.log('\nInstall this into a local TREK to try it. To ship it: `trek-plugin publish --repo --tag `.'); } } else if (command === 'keygen') { const keyPath = f.key || defaultKeyPath(); const { publicKey } = generateKeypair(keyPath); if (tui) { note(`Signing key written to ${keyPath}\nKeep it safe + BACK IT UP — losing it means you can't ship signed updates.\n\nauthorPublicKey (goes in your registry entry):\n${publicKey}`, 'Signing key'); - logInfo('Sign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + logInfo('`trek-plugin publish` will now offer to sign with this key — you do not need to pass --sign.'); } else { console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); - console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + console.log('\nPass --sign to `trek-plugin publish` to sign a release with it (interactively, publish offers).'); } } else if (command === 'sign') { const zip = positional[0] || 'plugin.zip'; @@ -289,15 +356,34 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis } } else if (command === 'publish') { const { repo, tag } = await ensureRepoTag(f, 'publish needs --repo and --tag '); + const dir = positional[0] || '.'; + + // Signing used to be a flag you had to already know about, with `keygen` buried under + // "Advanced…" — so the default path shipped unsigned and never mentioned that signing existed. + // Offer it, once, at the moment it is actually a decision. A script that did not pass --sign + // gets exactly the behaviour it always got: prompting a pipeline is how you hang a pipeline. + let signKeyPath = signKey(f); + let signing: SigningState | undefined; + if (tui && !signKeyPath) { + const id = readPluginId(dir); + if (id) { + signing = await inspectSigning(id, { registry: f.registry, keyPath: f.key }); + signKeyPath = await proposeSigning(signing); + } + } + if (tui) { - note(`repo ${repo}\ntag ${tag}\ndir ${positional[0] || '.'}`, 'Publish'); + note( + `repo ${repo}\ntag ${tag}\ndir ${dir}\nsign ${signKeyPath ? `yes (${signKeyPath})` : 'no — unsigned'}`, + 'Publish', + ); const ok = await promptConfirm({ message: 'Create the GitHub release and open the registry PR?', initialValue: true }); if (!ok) { outro('Cancelled — nothing was published.'); return; } } const { prUrl } = await publishPlugin({ - dir: positional[0] || '.', repo, tag, - signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft, - notes: f.notes, skipPreflight: !!f['no-preflight'], force: !!f.force, + 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, now: new Date().toISOString(), log: tui ? clackLogSink : undefined, }); @@ -347,7 +433,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); } } else { - console.error(USAGE); + console.error(`unknown command: ${command}\n${USAGE}`); process.exit(2); } } diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts index 546620c43..5c9d6a763 100644 --- a/plugin-sdk/src/cli/validate.ts +++ b/plugin-sdk/src/cli/validate.ts @@ -1,15 +1,20 @@ #!/usr/bin/env node /** - * trek-plugin validate [dir] (#plugins, M6). Runs the SAME manifest checks the - * registry CI runs, plus a light README sanity check — so a local pass predicts - * a CI pass. Returns a structured result; the CLI prints + exits non-zero on - * failure. + * trek-plugin validate [dir] — the gate. + * + * Runs every check the registry enforces that can be answered from the working tree, and exits + * non-zero if any of them would reject the plugin. That is a much stronger claim than it used to + * be: this file previously ran about a fifth of the registry's rules, and demoted the two that + * bite hardest — a bad `icon` and an unwritten README — to warnings. A green validate was not + * evidence of anything, and the first real verdict arrived from `preflight`, over the network, + * against a tag whose GitHub release was already immutable. + * + * The rules themselves live in ./checks/, shared with `status`, `preflight` and `publish`. This + * file is now only a printer and an exit code. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js'; -import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js'; -import { readJsonFile } from './json.js'; +import { loadContext } from './checks/context.js'; +import { runOffline } from './checks/index.js'; +import { renderPlain } from './checks/report.js'; export interface ValidateReport { ok: boolean; @@ -17,95 +22,35 @@ export interface ValidateReport { warnings: string[]; } +/** + * One string per problem, for callers that want a flat list rather than a rendered checklist. + * + * The `fix` is folded in, not dropped. Several checks summarise in `detail` ("2 problems") and put + * the specifics in `fix` ("• tag \"Flight Tracking\" is not a lowercase slug…") — so a flat list + * built from title + detail alone would report a failure without ever saying WHAT failed, which + * is the exact uselessness this rework is about. + */ +function flatten(outcomes: { title: string; detail?: string; fix?: string }[]): string[] { + return outcomes.map((o) => { + const head = o.detail ? `${o.title} — ${o.detail}` : o.title; + return o.fix ? `${head}\n${o.fix}` : head; + }); +} + export function validatePluginDir(dir: string): ValidateReport { - const errors: string[] = []; - const warnings: string[] = []; - - const manifestPath = path.join(dir, 'trek-plugin.json'); - if (!fs.existsSync(manifestPath)) return { ok: false, errors: ['no trek-plugin.json in ' + dir], warnings }; - - let manifestId = ''; - try { - const raw = readJsonFile>(manifestPath); - const res = validateManifest(raw); - errors.push(...res.errors); - manifestId = res.manifest?.id ?? String(raw.id ?? ''); - // dir name should match the id - if (manifestId && path.basename(path.resolve(dir)) !== manifestId) { - warnings.push(`directory name should equal the plugin id "${manifestId}"`); - } - // A `trek` of "*" installs on every TREK ever released and every one still to come. - // It is legal — a plugin may honestly have no version-specific surface — but it is far - // more often an author who didn't want to think about it, and it opts them out of the - // one mechanism that stops their plugin running on a host it was never tested against. - if (isUnboundedRange(res.manifest?.trek ?? '')) { - warnings.push('trek: "*" claims support for every TREK version, including ones that do not exist yet — pin a range like ">=3.2.0 <4.0.0"'); - } - // A well-formed but unrecognised addon id is not an error (the plugin may target - // a newer TREK), but it can never enable here — surface it as a warning. - for (const a of res.manifest?.requiredAddons ?? []) { - if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`); - } - // Same deal for `icon`: TREK resolves it against lucide at render time and falls - // back to Blocks on a name it can't find, so a typo is silent in the UI. Warn - // rather than error — the list is a snapshot, and a newer lucide may have names - // this SDK predates. - const icon = typeof raw.icon === 'string' ? raw.icon : ''; - if (icon && !LUCIDE_ICON_NAMES.has(icon)) { - warnings.push(`icon: "${icon}" is not a known lucide icon name on this SDK version — TREK will fall back to Blocks`); - } - // TREK renders its UI with lucide icons only. It STRIPS emojis from the declarative - // text your hooks return (badges, columns, warnings, PDF sections, map/calendar/photo - // labels) and from notifications, so emojis in that text simply vanish at render. - // Emojis in the manifest name/description are the tell-tale sign — nudge the author - // to use the `icon` field with a lucide name instead. - const EMOJI_RE = /[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}]/u; - if (EMOJI_RE.test(String(raw.name ?? '')) || EMOJI_RE.test(String(raw.description ?? ''))) { - warnings.push('name/description contains emojis — TREK uses lucide icons and strips emojis from plugin-rendered text + notifications. Use the declarative `icon` field (a lucide name) instead of emojis in labels.'); - } - } catch (e) { - errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e)); - } - - // README sanity - const readmePath = path.join(dir, 'README.md'); - if (!fs.existsSync(readmePath)) { - errors.push('README.md is missing'); - } else { - const md = fs.readFileSync(readmePath, 'utf8'); - if (!/!\[[^\]]*\]\([^)]+\)/.test(md)) warnings.push('README has no screenshot (registry requires at least one)'); - if (/\{\{[^}]*\}\}|REPLACE_ME|Describe (what|the)|One sentence:/i.test(md)) { - warnings.push('README still contains template placeholders — fill it in before publishing'); - } - } - - // Client UI: a raw but does not inline the design kit — native dropdowns will not match TREK. Add the marker so selects are auto-styled, or mark a field data-trek-native to opt out.'); - } - } - - // Server entry present - if (!fs.existsSync(path.join(dir, 'server', 'index.js'))) errors.push('server/index.js is missing (build your plugin first)'); - - return { ok: errors.length === 0, errors, warnings }; + const report = runOffline(loadContext(dir)); + return { + ok: report.ok, + errors: flatten(report.errors), + warnings: flatten(report.warnings), + }; } if (process.argv[1] && process.argv[1].endsWith('validate.js')) { const dir = process.argv[2] || '.'; - const r = validatePluginDir(dir); - for (const w of r.warnings) console.warn('warning: ' + w); - if (r.ok) { - console.log('✓ plugin is valid'); - } else { - for (const e of r.errors) console.error('error: ' + e); - process.exit(1); - } + const report = runOffline(loadContext(dir)); + const text = renderPlain(report); + if (text) console.error(text); + if (!report.ok) process.exit(1); + console.log('✓ plugin is valid'); } diff --git a/plugin-sdk/test/checks-parity.test.ts b/plugin-sdk/test/checks-parity.test.ts new file mode 100644 index 000000000..2c332b284 --- /dev/null +++ b/plugin-sdk/test/checks-parity.test.ts @@ -0,0 +1,205 @@ +/** + * The test this whole rework rests on. + * + * The SDK's checks are a PORT of the TREK-Plugins registry's gates (scripts/validate-entry.mjs + * and scripts/check-readme.mjs). A port drifts. And when it drifts in the lenient direction the + * result is not a missing feature — it is a FALSE GREEN: an author runs `validate`, sees a pass, + * cuts an immutable GitHub release, and the registry rejects it. That is the exact failure this + * rework exists to make impossible, so it needs a test, not a comment. + * + * Two halves: + * + * 1. The README gate's constants are read out of the registry's own source and compared. If + * someone bumps MIN_PROSE_CHARS to 500 over there, this goes red here. + * 2. The registry's REAL validate-entry.mjs is executed (SKIP_NETWORK=1) against an entry the + * SDK built, and must agree that it is valid. + * + * The registry lives in a separate repo, so these skip when it is not checked out — a plugin-sdk + * CI run without it still passes. Point TREK_PLUGINS_REPO at it to run them anywhere. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { scaffold } from '../src/cli/create.js'; +import { packPluginDir } from '../src/cli/pack.js'; +import { buildEntry } from '../src/cli/entry.js'; +import { validatePluginDir } from '../src/cli/validate.js'; +import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, PLACEHOLDER_PATTERNS } from '../src/cli/checks/readme.js'; +import { makePublishable } from './helpers.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REGISTRY = + process.env.TREK_PLUGINS_REPO ?? path.resolve(HERE, '..', '..', '..', 'TREK-Plugins'); +const haveRegistry = fs.existsSync(path.join(REGISTRY, 'scripts', 'validate-entry.mjs')); + +const describeIfRegistry = haveRegistry ? describe : describe.skip; + +describeIfRegistry('parity with the TREK-Plugins registry gates', () => { + let tmp: string; + let dir: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'parity-')); + scaffold('parity-plug', 'widget', tmp); + dir = path.join(tmp, 'parity-plug'); + }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + // ── 1. the README gate's constants ──────────────────────────────────────────── + + const readmeGateSource = (): string => + fs.readFileSync(path.join(REGISTRY, 'scripts', 'check-readme.mjs'), 'utf8'); + + it('requires exactly the sections the registry requires', () => { + const src = readmeGateSource(); + const match = src.match(/const REQUIRED_HEADINGS = \[([^\]]+)\]/); + expect(match, 'could not find REQUIRED_HEADINGS in check-readme.mjs').toBeTruthy(); + const theirs = [...match![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + expect(REQUIRED_SECTIONS).toEqual(theirs); + }); + + it('uses the same prose floor as the registry', () => { + const match = readmeGateSource().match(/const MIN_PROSE_CHARS = (\d+)/); + expect(match, 'could not find MIN_PROSE_CHARS in check-readme.mjs').toBeTruthy(); + expect(MIN_PROSE_CHARS).toBe(Number(match![1])); + }); + + it('detects the same template placeholders as the registry', () => { + const src = readmeGateSource(); + const block = src.match(/const PLACEHOLDER_PATTERNS = \[([\s\S]*?)\n\]/); + expect(block, 'could not find PLACEHOLDER_PATTERNS in check-readme.mjs').toBeTruthy(); + // Compare the regex SOURCES, not the objects — same patterns, whatever the formatting. + const theirs = [...block![1].matchAll(/\/((?:[^/\\\n]|\\.)+)\/[a-z]*/g)].map((m) => m[1]).sort(); + const ours = PLACEHOLDER_PATTERNS.map((r) => r.source).sort(); + expect(ours).toEqual(theirs); + }); + + // ── 2. the registry's real validator, run against an entry we built ─────────── + + /** + * Build a publishable plugin, pack it, and hand the resulting entry to the registry's OWN + * validate-entry.mjs. This is the end of the chain: if the registry's script says yes with + * SKIP_NETWORK=1, then everything the SDK can promise offline, it has promised correctly. + */ + function runRegistryValidator(entry: unknown, id: string): { code: number; output: string } { + const entryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entry-')); + const entryPath = path.join(entryDir, `${id}.json`); + fs.writeFileSync(entryPath, JSON.stringify(entry, null, 2)); + try { + const output = execFileSync('node', [path.join(REGISTRY, 'scripts', 'validate-entry.mjs'), entryPath], { + encoding: 'utf8', + env: { ...process.env, SKIP_NETWORK: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, output }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? 1, output: (err.stdout ?? '') + (err.stderr ?? '') }; + } finally { + fs.rmSync(entryDir, { recursive: true, force: true }); + } + } + + function entryFor(pluginDir: string) { + const zip = path.join(pluginDir, 'plugin.zip'); + packPluginDir(pluginDir, zip); + return buildEntry({ + dir: pluginDir, + repo: 'someone/trek-plugin-parity-plug', + tag: 'v1.0.0', + zipPath: zip, + // No git tag exists in a temp dir, so pin the commit by hand — the network checks that + // would verify it are exactly the ones SKIP_NETWORK=1 turns off. + commit: 'a'.repeat(40), + now: '2026-01-01T00:00:00Z', + }); + } + + it('an entry the SDK builds passes the registry validator', () => { + makePublishable(dir); + expect(validatePluginDir(dir).ok).toBe(true); + + const { code, output } = runRegistryValidator(entryFor(dir), 'parity-plug'); + expect(output).not.toMatch(/FAIL|schema:/); + expect(code).toBe(0); + }); + + /** + * The buildEntry bug, pinned. + * + * buildEntry never emitted requiredAddons or pluginDependencies, but validate-entry.mjs + * parity-checks both against the manifest. So any plugin that actually declared an addon + * dependency produced an entry the registry rejected — and the author found out after the + * release was cut and its bytes pinned. This test is the reason that cannot happen again. + */ + it('an entry for a plugin with requiredAddons still passes the registry validator', () => { + const manifestPath = path.join(dir, 'trek-plugin.json'); + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + m.requiredAddons = ['budget', 'packing']; + m.pluginDependencies = [{ id: 'some-other-plugin', version: '>=1.0.0 <2.0.0' }]; + fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2)); + makePublishable(dir); + + const entry = entryFor(dir); + // The entry must MIRROR the manifest, or the registry's parity check rejects it. + expect(entry.versions[0].requiredAddons).toEqual(['budget', 'packing']); + expect(entry.versions[0].pluginDependencies).toEqual([{ id: 'some-other-plugin', version: '>=1.0.0 <2.0.0' }]); + + const { code, output } = runRegistryValidator(entry, 'parity-plug'); + expect(output).not.toMatch(/FAIL|requiredAddons|pluginDependencies/); + expect(code).toBe(0); + }); + + /** + * `tags` and `homepage` are copied verbatim out of the manifest into the entry, and the + * registry's JSON schema validates both — but nothing local used to. A tag with a capital + * letter (the obvious thing to write) produced an entry CI rejected, after the release was cut. + * + * Both halves are asserted: WE reject it, and the registry rejects it too. Either one alone + * would be a weaker claim than it looks. + */ + it.each([ + { field: 'tags', value: ['Flight Tracking'], match: /tag/i }, + { field: 'homepage', value: 'not-a-url', match: /homepage/i }, + ])('rejects a bad $field, exactly as the registry schema does', ({ field, value, match }) => { + const manifestPath = path.join(dir, 'trek-plugin.json'); + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + m[field] = value; + fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2)); + makePublishable(dir); + + const ours = validatePluginDir(dir); + expect(ours.ok).toBe(false); + expect(ours.errors.some((e) => match.test(e))).toBe(true); + + const { code, output } = runRegistryValidator(entryFor(dir), 'parity-plug'); + expect(code).toBe(1); + expect(output).toMatch(/schema:/); + }); + + /** + * The registry's schema caps `description` at 200 chars and requires at least 5. Nothing local + * used to look at either, so an over-long description sailed through pack + release and failed + * CI once the artifact was immutable. Assert that we now catch it BEFORE the registry does. + */ + it('rejects an over-long description that the registry schema would also reject', () => { + const manifestPath = path.join(dir, 'trek-plugin.json'); + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + m.description = 'x'.repeat(201); + fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2)); + makePublishable(dir); + + // We say no… + const ours = validatePluginDir(dir); + expect(ours.ok).toBe(false); + expect(ours.errors.some((e) => /description/.test(e))).toBe(true); + + // …and so does the registry, for the same reason. That agreement is the point. + const { code, output } = runRegistryValidator(entryFor(dir), 'parity-plug'); + expect(code).toBe(1); + expect(output).toMatch(/description/); + }); +}); diff --git a/plugin-sdk/test/checks.test.ts b/plugin-sdk/test/checks.test.ts new file mode 100644 index 000000000..aed36deda --- /dev/null +++ b/plugin-sdk/test/checks.test.ts @@ -0,0 +1,260 @@ +/** + * Per-check unit tests. + * + * Every check is a pure function of a CheckContext, so these need no temp dir and no disk — the + * context is just data, and `exists` is a closure the test supplies. That is the property the + * whole checks/ module was shaped around, and it is what makes the boundary cases (399 vs 400 + * chars of prose) cheap enough to actually write down. + */ +import { describe, it, expect } from 'vitest'; +import { runOffline } from '../src/cli/checks/index.js'; +import { blocking, type CheckContext, type CheckReport } from '../src/cli/checks/types.js'; +import { proseLength, missingSections, placeholders, images, undocumentedPermissions } from '../src/cli/checks/readme.js'; + +/** A manifest that passes everything, so each test can break exactly one thing. */ +const GOOD_MANIFEST = { + id: 'flight-tracker', + name: 'Flight Tracker', + version: '1.0.0', + apiVersion: 1, + author: 'Jane Doe', + description: 'Shows your next flight on the trip dashboard.', + type: 'widget', + trek: '>=3.4.0 <4.0.0', + icon: 'Plane', + nativeModules: false, + permissions: ['db:own'], + requiredAddons: [], + pluginDependencies: [], +}; + +/** A README that passes every registry gate. Long enough to clear the 400-char prose floor. */ +const GOOD_README = `# Flight Tracker + +![screenshot](./docs/screenshot.png) + +## What it does + +This plugin shows your next flight on the trip dashboard, so you never have to dig through your +inbox for a boarding pass again. It reads the trip you are looking at, finds the next reservation +of type flight, and renders it as a widget with the gate, the terminal and a countdown. There is +nothing to configure and nothing to sign up for, and it works offline once the trip has synced. + +## Screenshots + +The image above shows the widget on a trip dashboard. + +## Permissions + +| Permission | Why | +|---|---| +| \`db:own\` | Stores the cached flight lookup so the widget renders instantly. | + +## Setup + +Install it and enable it. There is nothing to configure. +`; + +function ctx(over: Partial = {}): CheckContext { + const files = new Set(['server/index.js', 'docs/screenshot.png']); + return { + dir: '/plugins/flight-tracker', + manifest: { ...GOOD_MANIFEST }, + readme: GOOD_README, + exists: (rel) => files.has(rel.replace(/^\.?\//, '')), + ...over, + }; +} + +const failed = (r: CheckReport, id: string) => r.errors.some((e) => e.id === id); +const warned = (r: CheckReport, id: string) => r.warnings.some((w) => w.id === id); + +describe('the baseline is actually clean', () => { + it('a well-formed plugin passes every offline check', () => { + const r = runOffline(ctx()); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + }); +}); + +describe('manifest checks', () => { + it('rejects a description longer than the registry allows', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, description: 'x'.repeat(201) } })); + expect(failed(r, 'manifest.entry-fields')).toBe(true); + }); + + it('rejects a missing description — the entry would carry an empty one and fail the schema', () => { + const m = { ...GOOD_MANIFEST }; + delete (m as Record).description; + expect(failed(runOffline(ctx({ manifest: m })), 'manifest.entry-fields')).toBe(true); + }); + + it('accepts a description at exactly the 200-char cap', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, description: 'x'.repeat(200) } })); + expect(failed(r, 'manifest.entry-fields')).toBe(false); + }); + + it('rejects an icon lucide does not have — the registry does', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, icon: 'Aeroplane' } })); + expect(failed(r, 'manifest.icon')).toBe(true); + }); + + it('allows no icon at all (TREK falls back to Blocks) but says so', () => { + const m = { ...GOOD_MANIFEST }; + delete (m as Record).icon; + const r = runOffline(ctx({ manifest: m })); + expect(failed(r, 'manifest.icon')).toBe(false); + expect(r.outcomes.find((o) => o.id === 'manifest.icon')?.detail).toMatch(/Blocks/); + }); + + it('rejects a homoglyph name that mixes Latin with Cyrillic', () => { + // "Flight Trаcker" — the 'а' is Cyrillic U+0430. + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, name: 'Flight Trаcker' } })); + expect(failed(r, 'manifest.entry-fields')).toBe(true); + }); + + it('warns — but does not fail — on an unbounded trek range', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, trek: '*' } })); + expect(warned(r, 'manifest.trek-range')).toBe(true); + expect(failed(r, 'manifest.trek-range')).toBe(false); + }); +}); + +/** + * The egress trap. + * + * TREK builds the child's network allow-list and the iframe CSP from the `http:outbound:` + * PERMISSIONS. It never reads the manifest's `egress[]` array — that is a declaration for the + * consent screen. So a host in egress[] with no matching permission is silently unreachable at + * runtime: the plugin installs, activates, consents, and every request to it is blocked. Nothing + * anywhere caught this before. + */ +describe('the egress trap', () => { + const outbound = (permissions: string[], egress: string[]) => + runOffline(ctx({ manifest: { ...GOOD_MANIFEST, permissions, egress } })); + + it('fails a host declared in egress[] with only the bare http:outbound permission', () => { + const r = outbound(['db:own', 'http:outbound'], ['api.example.com']); + expect(failed(r, 'code.egress-reachable')).toBe(true); + const e = r.errors.find((x) => x.id === 'code.egress-reachable'); + // The fix has to name the exact permission string, or the author is left guessing. + expect(e?.fix).toContain('"http:outbound:api.example.com"'); + }); + + it('passes when every egress host has its matching per-host permission', () => { + const r = outbound(['db:own', 'http:outbound:api.example.com'], ['api.example.com']); + expect(failed(r, 'code.egress-reachable')).toBe(false); + }); + + it('warns when a host is reachable but not declared in egress[] (the consent screen understates it)', () => { + const r = outbound(['db:own', 'http:outbound:api.example.com'], []); + expect(warned(r, 'code.egress-declared')).toBe(true); + }); +}); + +describe('README checks', () => { + it('fails a README that is one char short of the prose floor, and passes one that is exactly at it', () => { + // Build prose of an exact length, with all four sections present so only the floor is in play. + const withProse = (n: number) => + GOOD_README.replace( + /This plugin shows[\s\S]*?has synced\./, + 'x'.repeat(n), + ); + const proseOf = (md: string) => proseLength(md); + + const short = withProse(1); + const long = withProse(2000); + expect(proseOf(short)).toBeLessThan(400); + expect(proseOf(long)).toBeGreaterThanOrEqual(400); + + expect(failed(runOffline(ctx({ readme: short })), 'docs.readme-prose')).toBe(true); + expect(failed(runOffline(ctx({ readme: long })), 'docs.readme-prose')).toBe(false); + }); + + it('fails a README missing a required section', () => { + const r = runOffline(ctx({ readme: GOOD_README.replace('## Setup', '## Configuration') })); + expect(failed(r, 'docs.readme-sections')).toBe(true); + }); + + it('fails a README with a permission it never explains', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, permissions: ['db:own', 'weather:read'] } })); + expect(failed(r, 'docs.permissions-explained')).toBe(true); + expect(r.errors.find((e) => e.id === 'docs.permissions-explained')?.detail).toContain('weather:read'); + }); + + it('fails template placeholders left in the README', () => { + const r = runOffline(ctx({ readme: GOOD_README.replace('This plugin shows', 'Describe what it does.') })); + expect(failed(r, 'docs.readme-placeholders')).toBe(true); + }); + + /** + * The old check regexed for an image LINK and passed if it found one — so the scaffold's + * `![screenshot](./docs/screenshot.png)`, pointing at a file that was never created, satisfied + * it. Resolve the path instead. + */ + it('fails a screenshot link whose file does not exist', () => { + const r = runOffline(ctx({ exists: (rel) => rel === 'server/index.js' })); + expect(failed(r, 'docs.screenshot')).toBe(true); + expect(r.errors.find((e) => e.id === 'docs.screenshot')?.next).toBe('trek-plugin shot'); + }); + + it('fails a README with no image at all', () => { + const r = runOffline(ctx({ readme: GOOD_README.replace('![screenshot](./docs/screenshot.png)', '') })); + expect(failed(r, 'docs.screenshot')).toBe(true); + }); +}); + +/** + * `pack` must not be blocked by a gate that only matters at publish time — packing is how you + * install a plugin into a local TREK to try it, and an unwritten README does not stop it running. + */ +describe('what blocks what', () => { + it('an undocumented plugin blocks publish but not the artifact', () => { + const undocumented = ctx({ readme: '# Nothing here\n', exists: (rel) => rel === 'server/index.js' }); + const full = runOffline(undocumented); + expect(full.ok).toBe(false); + + expect(blocking(full, 'artifact').ok).toBe(true); + }); + + it('a broken manifest blocks the artifact too — that plugin could never load', () => { + const broken = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, trek: 'not-a-range' } })); + expect(blocking(broken, 'artifact').ok).toBe(false); + }); + + it('a missing server entry blocks the artifact', () => { + const r = runOffline(ctx({ exists: (rel) => rel === 'docs/screenshot.png' })); + expect(blocking(r, 'artifact').ok).toBe(false); + expect(failed(r, 'code.server-entry')).toBe(true); + }); +}); + +describe('README helpers mirror the registry exactly', () => { + it('strips headings, tables, links, images and code fences before measuring prose', () => { + const md = '# Title\n\n![x](y.png)\n\n| a | b |\n|---|---|\n\n```js\nconst x = 1;\n```\n\n[link](u)\n'; + expect(proseLength(md)).toBeLessThan(20); // nothing above is prose + }); + + it('finds required sections case-insensitively and as substrings', () => { + expect(missingSections('## What it does\n## Screenshots\n## Permissions\n## Setup')).toEqual([]); + expect(missingSections('### WHAT IT DOES and more\n## Screenshots\n## Permissions\n## Setup')).toEqual([]); + expect(missingSections('## What it does')).toEqual(['Screenshots', 'Permissions', 'Setup']); + }); + + it('ignores data: URIs when looking for a screenshot, like the registry does', () => { + expect(images('![a](data:image/png;base64,xxx)')).toEqual([]); + expect(images('')).toEqual(['shot.png']); + }); + + it('matches a permission anywhere in the README, case-insensitively', () => { + expect(undocumentedPermissions('we use `DB:OWN` for caching', ['db:own'])).toEqual([]); + expect(undocumentedPermissions('nothing here', ['db:own'])).toEqual(['db:own']); + }); + + it('spots each template placeholder the registry spots', () => { + expect(placeholders('Describe what your plugin does.')).toHaveLength(1); + expect(placeholders('see your-name/trek-plugin-x')).toHaveLength(1); + expect(placeholders('{{ TODO }}')).toHaveLength(1); + expect(placeholders('a real README')).toEqual([]); + }); +}); diff --git a/plugin-sdk/test/helpers.ts b/plugin-sdk/test/helpers.ts new file mode 100644 index 000000000..e241f7590 --- /dev/null +++ b/plugin-sdk/test/helpers.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Take a scaffolded plugin and do to it what a real author does before publishing: write the + * README and commit a screenshot. + * + * This is exactly the set of things the registry demands and the scaffold deliberately leaves + * undone, so it doubles as executable documentation of the gap between "it runs" and "it ships". + * A test that needs a PUBLISHABLE plugin calls this; a test that needs a FRESH one does not, and + * the difference between them is the contract. + * + * Every permission in the manifest gets a line, because the registry requires each one to be + * explained by name, and the prose has to clear 400 real characters (headings, tables, links and + * code fences are stripped before it is measured, so a big table will not carry you). + */ +export function makePublishable(dir: string): void { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as { + name?: string; + permissions?: unknown; + }; + const permissions: string[] = Array.isArray(manifest.permissions) ? manifest.permissions.map(String) : []; + + fs.mkdirSync(path.join(dir, 'docs'), { recursive: true }); + // A 1×1 PNG. The gate is "the file the README links actually exists", not "it is beautiful". + fs.writeFileSync( + path.join(dir, 'docs', 'screenshot.png'), + Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'), + ); + + fs.writeFileSync( + path.join(dir, 'README.md'), + `# ${manifest.name ?? 'Plugin'}\n\n` + + '![screenshot](./docs/screenshot.png)\n\n' + + '## What it does\n\n' + + 'This plugin adds a genuinely useful capability to TREK, and this paragraph exists to clear the four hundred\n' + + 'character floor the registry puts on real prose. It explains, in ordinary sentences, what a traveller gets\n' + + 'out of installing it and why they might want to. The registry strips headings, tables, links and code fences\n' + + 'before it measures, so padding the file out with a permissions table will not help; it has to be words.\n\n' + + '## Screenshots\n\n' + + 'The image above shows the plugin running inside a trip.\n\n' + + '## Permissions\n\n' + + '| Permission | Why |\n|---|---|\n' + + permissions.map((p) => `| \`${p}\` | Needed so the plugin can do its job. |`).join('\n') + + '\n\n## Setup\n\nInstall it from the plugin store and enable it. There is nothing else to configure.\n\n' + + '## License\n\nMIT.\n', + ); +} diff --git a/plugin-sdk/test/publish.test.ts b/plugin-sdk/test/publish.test.ts index 66b33ce4e..758383aab 100644 --- a/plugin-sdk/test/publish.test.ts +++ b/plugin-sdk/test/publish.test.ts @@ -22,6 +22,7 @@ vi.mock('node:child_process', () => ({ const { publishPlugin } = await import('../src/cli/publish.js'); const { scaffold } = await import('../src/cli/create.js'); +const { makePublishable } = await import('./helpers.js'); describe('publish — a released artifact is immutable', () => { let tmp: string; @@ -33,6 +34,9 @@ describe('publish — a released artifact is immutable', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-')); scaffold('immutable-plug', 'integration', tmp); dir = path.join(tmp, 'immutable-plug'); + // publish now gates on the registry's checks BEFORE it packs or releases, so a bare scaffold + // never reaches the `gh` calls these tests are about. Document the plugin first. + makePublishable(dir); }); afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); @@ -71,3 +75,107 @@ describe('publish — a released artifact is immutable', () => { expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(true); }); }); + +/** + * The reorder, and why it is the most important thing in this file. + * + * publish used to run: pack → tag + GitHub release → preflight → submit. So the checks that + * catch an unwritten README or a missing screenshot ran AFTER the release was cut — and a release + * is immutable in the only sense that matters, because the registry pins its sha256. An author + * who failed preflight had already burned their v1.0.0 tag, and the only way out was to throw it + * away and cut a v1.0.1 whose sole change was the README. + * + * So: when a local gate fails, NOTHING may happen. No zip, no tag, no push, no release. + */ +describe('publish — the local gates run before anything is cut', () => { + let tmp: string; + let dir: string; + + beforeEach(() => { + calls.length = 0; + releaseExistsOnRemote = false; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-gate-')); + scaffold('gated-plug', 'integration', tmp); + dir = path.join(tmp, 'gated-plug'); + // Deliberately NOT made publishable: this is a fresh scaffold, exactly what a first-time + // author has when they reach for `publish` too early. + }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + const run = () => publishPlugin({ + dir, repo: 'someone/trek-plugin-gated-plug', tag: 'v1.0.0', + now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {}, + }); + + it('refuses an undocumented plugin and names what is wrong', async () => { + await expect(run()).rejects.toThrow(/would be rejected by the registry/i); + await expect(run()).rejects.toThrow(/prose|screenshot/i); + }); + + it('packs nothing, tags nothing, pushes nothing, releases nothing', async () => { + await run().catch(() => {}); + + expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(false); + expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag'))).toBe(false); + expect(calls.some((c) => c.bin === 'git' && c.args.includes('push'))).toBe(false); + expect(calls.some((c) => c.bin === 'gh')).toBe(false); + }); + + it('goes through once the plugin is documented', async () => { + makePublishable(dir); + await run().catch(() => {}); // submit is not stubbed; we only care that it got past the gate + expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'create')).toBe(true); + }); +}); + +/** + * The signing half of the same trap. + * + * TREK pins the author key on first install. A plugin that shipped SIGNED and then publishes + * UNSIGNED is refused on every instance that already has it (SIGNATURE_MISSING) — the update is + * not merely rejected, it strands every existing user on the version they have. + * + * `preflight` has always caught this. But preflight is step 4, and the immutable GitHub release is + * 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. + */ +describe('publish — a signed plugin cannot quietly go unsigned', () => { + let tmp: string; + let dir: string; + + beforeEach(() => { + calls.length = 0; + releaseExistsOnRemote = false; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-sign-')); + scaffold('signed-plug', 'integration', tmp); + dir = path.join(tmp, 'signed-plug'); + makePublishable(dir); + }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + /** Pretend the registry already lists this plugin as signed, without hitting the network. */ + const publishedSigned = { + hasKey: true, + keyPath: '/nonexistent/signing.key', + publishedSigned: true, + publishedKey: 'a-published-key', + }; + + const run = (signKeyPath?: string) => publishPlugin({ + dir, repo: 'someone/trek-plugin-signed-plug', tag: 'v2.0.0', + now: '2026-01-01T00:00:00Z', skipPreflight: true, signKeyPath, + signing: publishedSigned, log: () => {}, + }); + + it('refuses, and names what it would have broken', async () => { + await expect(run(undefined)).rejects.toThrow(/published SIGNED/i); + await expect(run(undefined)).rejects.toThrow(/strand every existing user/i); + }); + + it('cuts no release — the whole point of checking at step 1', async () => { + await run(undefined).catch(() => {}); + expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(false); + expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag'))).toBe(false); + expect(calls.some((c) => c.bin === 'gh')).toBe(false); + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index ce128fdb2..ae9070d09 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -12,6 +12,7 @@ import { makeZip, listZipNames } from '../src/zip.js'; import { packPluginDir } from '../src/cli/pack.js'; import { buildEntry } from '../src/cli/entry.js'; import { generateKeypair, signArtifact, publicKeyBase64, verifyArtifact, loadPrivateKey } from '../src/cli/sign.js'; +import { makePublishable } from './helpers.js'; /** A central-directory zip reader mirroring the TREK server's, to prove round-trip. */ function readZip(buf: Buffer): Record { @@ -397,42 +398,78 @@ describe('scaffold + validate CLIs', () => { beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-')); }); afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); - it('scaffolds a widget plugin that then validates (with README warnings)', () => { + /** + * A scaffold is HONESTLY INCOMPLETE: it builds and runs, but it is not publishable until it is + * documented. These two facts are the contract, and they are deliberately different: + * + * pack/dev work immediately — you can install it into a local TREK and try it. + * validate fails until the README is written and a screenshot exists. + * + * It used to be the other way round: a fresh scaffold passed `validate` (which only checked a + * fifth of the registry's rules and demoted the README to a warning), and the author learned + * the truth from CI — after cutting an immutable release. A green that means nothing is worse + * than a red that tells you what to do. + */ + it('scaffolds a widget that builds immediately but is not yet publishable', () => { scaffold('my-widget', 'widget', tmp); const dir = path.join(tmp, 'my-widget'); expect(fs.existsSync(path.join(dir, 'trek-plugin.json'))).toBe(true); expect(fs.existsSync(path.join(dir, 'server', 'index.js'))).toBe(true); expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); + // It packs — the dev loop is not blocked by the docs gates. + expect(() => packPluginDir(dir, path.join(tmp, 'w.zip'))).not.toThrow(); + + // But it does not publish, and it says exactly why. Both of these used to be WARNINGS, which + // is how a scaffold could sail through `validate` and then be rejected by the registry. const r = validatePluginDir(dir); - expect(r.ok).toBe(true); // manifest + files valid - expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template + expect(r.ok).toBe(false); + expect(r.errors.some((e) => /placeholder/.test(e))).toBe(true); + // The scaffold's README references ./docs/screenshot.png and never creates it. The old check + // regexed for an image LINK and passed; this one resolves the path. + expect(r.errors.some((e) => /screenshot/.test(e))).toBe(true); + expect(fs.existsSync(path.join(dir, 'docs', 'screenshot.png'))).toBe(false); }); - // TREK resolves `icon` against lucide at render time and falls back to Blocks on a - // name it can't find, so a typo is invisible in the UI — validate has to surface it. - it('warns on an icon lucide does not know, without failing validation', () => { + it('a documented scaffold passes validate clean — the scaffold is not born unfixable', () => { + scaffold('done-plug', 'widget', tmp); + const dir = path.join(tmp, 'done-plug'); + makePublishable(dir); + + const r = validatePluginDir(dir); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + }); + + /** + * The registry REJECTS an icon lucide does not have (validate-entry.mjs: "is not a lucide icon + * name"), so warning about it locally was a false green: the author saw a pass, cut the release, + * and CI failed them once the artifact was immutable. Local severity now matches the registry's. + */ + it('rejects an icon lucide does not know — the registry does, so we must too', () => { scaffold('icon-plug', 'widget', tmp); const dir = path.join(tmp, 'icon-plug'); + makePublishable(dir); const file = path.join(dir, 'trek-plugin.json'); const m = JSON.parse(fs.readFileSync(file, 'utf8')); fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2)); const r = validatePluginDir(dir); - expect(r.ok).toBe(true); // a bad icon is never a hard error - expect(r.warnings.some((w) => /icon: "Stethscope" is not a known lucide icon name/.test(w))).toBe(true); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => /Stethscope.*not a lucide icon/.test(e))).toBe(true); }); it('accepts any real lucide icon name, not just a curated few', () => { scaffold('icon-plug', 'widget', tmp); const dir = path.join(tmp, 'icon-plug'); + makePublishable(dir); const file = path.join(dir, 'trek-plugin.json'); const m = JSON.parse(fs.readFileSync(file, 'utf8')); fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2)); const r = validatePluginDir(dir); expect(r.ok).toBe(true); - expect(r.warnings.some((w) => /lucide icon name/.test(w))).toBe(false); + expect(r.errors.some((e) => /lucide icon/.test(e))).toBe(false); }); it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => { @@ -449,6 +486,7 @@ describe('scaffold + validate CLIs', () => { const m = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')); expect(m.type).toBe('trip-page'); expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); // non-integration → gets a UI + makePublishable(dir); expect(validatePluginDir(dir).ok).toBe(true); }); @@ -485,9 +523,16 @@ describe('scaffold + validate CLIs', () => { it('tolerates a UTF-8 BOM in trek-plugin.json (Windows editors add one)', () => { scaffold('bom-plug', 'integration', tmp); - const mp = path.join(tmp, 'bom-plug', 'trek-plugin.json'); + const dir = path.join(tmp, 'bom-plug'); + makePublishable(dir); + const mp = path.join(dir, 'trek-plugin.json'); fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8')); - expect(validatePluginDir(path.join(tmp, 'bom-plug')).ok).toBe(true); + + // A bare JSON.parse chokes on the BOM and reports "Unexpected token" against an invisible + // character \u2014 the manifest must still parse, and the plugin must still be publishable. + const r = validatePluginDir(dir); + expect(r.errors.some((e) => /not valid JSON/.test(e))).toBe(false); + expect(r.ok).toBe(true); }); it('validatePluginDir flags a missing manifest', () => { diff --git a/plugin-sdk/test/shot.test.ts b/plugin-sdk/test/shot.test.ts new file mode 100644 index 000000000..5e17977ef --- /dev/null +++ b/plugin-sdk/test/shot.test.ts @@ -0,0 +1,48 @@ +/** + * `shot` up to the point where it would need a browser. + * + * The screenshot path itself needs Playwright and a Chromium, which this suite has no business + * downloading — but everything that decides WHETHER to take a screenshot, and what to tell the + * author when it cannot, is ordinary logic and is where the useful behaviour lives. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { scaffold } from '../src/cli/create.js'; +import { runShot, ShotError } from '../src/cli/shot.js'; + +describe('shot', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shot-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('refuses a directory that is not a plugin', async () => { + await expect(runShot(tmp)).rejects.toThrow(/no trek-plugin.json/); + }); + + /** + * An integration plugin has no UI, so there is nothing to render — but it still NEEDS a + * screenshot, because the registry requires one and the store card shows it. Erroring out with + * "no UI" would be true and useless; say what to photograph instead. + */ + it('tells an integration author what to screenshot instead of failing blankly', async () => { + scaffold('int-plug', 'integration', tmp); + const err = await runShot(path.join(tmp, 'int-plug')).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ShotError); + const message = (err as Error).message; + expect(message).toMatch(/no UI/i); + // The useful half: what to do about it. + expect(message).toMatch(/docs\/screenshot\.png/); + expect(message).toMatch(/notification|badge|settings/i); + }); + + it('does not boot a browser just to discover the plugin has no UI', async () => { + // The integration check must come BEFORE loadChromium, or an author with no Playwright gets + // "install Playwright" for a plugin Playwright could never have helped with. + scaffold('int-plug', 'integration', tmp); + const err = await runShot(path.join(tmp, 'int-plug')).catch((e: unknown) => e); + expect((err as Error).message).not.toMatch(/playwright/i); + }); +}); diff --git a/plugin-sdk/test/signing.test.ts b/plugin-sdk/test/signing.test.ts new file mode 100644 index 000000000..323162eb0 --- /dev/null +++ b/plugin-sdk/test/signing.test.ts @@ -0,0 +1,52 @@ +/** + * Signing: the guard, and what it refuses to let you do. + * + * The PROMPT is interactive and not worth mocking a TTY for. The guard is the part that matters, + * because of what it prevents: a plugin that shipped signed and then publishes unsigned is refused + * by TREK on every instance that already has it (registry.service.ts, SIGNATURE_MISSING) — and + * before this, `preflight` caught that at step 4, AFTER the immutable GitHub release was cut. The + * author's tag was burned for a problem that was knowable before a single byte was packed. + */ +import { describe, it, expect } from 'vitest'; +import { assertSigningAllowed, type SigningState } from '../src/cli/signing.js'; + +const state = (over: Partial = {}): SigningState => ({ + hasKey: false, + keyPath: '/home/someone/.trek-plugin/signing.key', + publishedSigned: false, + ...over, +}); + +describe('assertSigningAllowed', () => { + it('allows an unsigned publish of a plugin that was never published signed', () => { + expect(() => assertSigningAllowed(state(), undefined)).not.toThrow(); + }); + + it('allows an unsigned publish even when a key happens to exist — signing stays a choice', () => { + // Having a key on the machine is not consent to sign. An author may keep one for another plugin. + expect(() => assertSigningAllowed(state({ hasKey: true }), undefined)).not.toThrow(); + }); + + it('allows a signed publish of a plugin that was published signed', () => { + const s = state({ hasKey: true, publishedSigned: true, publishedKey: 'abc' }); + expect(() => assertSigningAllowed(s, s.keyPath)).not.toThrow(); + }); + + it('REFUSES an unsigned publish of a plugin that was published signed', () => { + const s = state({ hasKey: true, publishedSigned: true, publishedKey: 'abc' }); + expect(() => assertSigningAllowed(s, undefined)).toThrow(/published SIGNED/i); + // The fix has to be actionable, and it has to name the key it found. + expect(() => assertSigningAllowed(s, undefined)).toThrow(/--sign/); + expect(() => assertSigningAllowed(s, undefined)).toThrow(new RegExp(s.keyPath)); + }); + + it('explains the lost-key case rather than telling you to pass a flag you cannot honour', () => { + // Telling an author with no key to "re-run with --sign" is advice they cannot take. The key is + // gone; the only route left is a registry maintainer override, and saying so is the whole value. + const s = state({ hasKey: false, publishedSigned: true, publishedKey: 'abc' }); + const run = () => assertSigningAllowed(s, undefined); + expect(run).toThrow(/LOST it/i); + expect(run).toThrow(/allow-key-change/); + expect(run).not.toThrow(/Re-run with --sign/); + }); +}); diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index fa96b3168..b0b511973 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -14,20 +14,31 @@ npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page cd my-plugin ``` -The wizard (run `create` with no name) asks for the id, type, author and -permissions; the direct form takes them as flags. +The wizard (run `create` with no name) asks for the id, type, author, icon (checked +against lucide — a typo there is invisible locally but rejected by the registry), +permissions, egress hosts and required addons; the direct form takes them as flags. This emits: ``` my-plugin/ - trek-plugin.json # manifest + trek-plugin.json # manifest — including `icon`, and a widget's `slot` package.json # CommonJS marker + the SDK as a devDependency server/index.js # your plugin code (built, plain JS) client/index.html # native UI via the design kit (page / widget / trip-page only) - README.md # fill this in — the registry requires a screenshot + README.md # fill this in — the registry requires four sections and a screenshot + docs/ # where docs/screenshot.png goes; `trek-plugin shot` writes it + .gitattributes # LF everywhere, so the packed artifact's sha256 is reproducible ``` +The scaffold declares `"trek": ">=3.4.0 <4.0.0"` — the `ctx` namespaces it is written +against only exist from 3.4.0, and claiming an older floor would let the plugin install +on a host where they are `undefined`. + +What you get **runs and packs immediately**, but it does **not** pass `validate` yet: +the README is still a template and there is no screenshot. That is deliberate — those +are yours to write — and `trek-plugin status` says exactly what is missing. + ## Run it locally with hot reload ```bash @@ -1027,7 +1038,12 @@ what your manifest declares. only). Vendor any *other* runtime deps: TREK never runs `npm install` on a plugin. - **Ship built JS** in `server/index.js` and pre-built static files in `client/`. `.ts` and `.map` files are stripped by `pack`. -- Declare every outbound host in `egress[]` whenever you use `http:outbound`. +- Declare every outbound host in `egress[]` whenever you use `http:outbound` — **and + grant each of them a matching `http:outbound:` permission**. The runtime builds + the child process's network allow-list and the iframe's CSP from those *permissions* + only; it never reads `egress[]`, which exists for the admin's consent screen. A host + listed in `egress[]` with no matching permission installs, activates, consents — and + is then silently unreachable. `validate` errors on this. ## Manifest reference (`trek-plugin.json`) @@ -1038,7 +1054,7 @@ what your manifest declares. | `version` | string, **required** | semver (`1.2.3`, optional pre-release). | | `apiVersion` | number | plugin API version (currently `1`; `PLUGIN_API_VERSION`). Defaults to `1`. | | `type` | string, **required** | `integration` \| `page` \| `widget` \| `trip-page`. | -| `trek` | string, **required** | the TREK versions this plugin supports, as a semver **range**: `">=3.2.0 <4.0.0"`. Must be *satisfiable* (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it — rejected). **Enforced at install and at activation** (see below); its lower bound is derived into `minTrekVersion` on the registry entry. | +| `trek` | string, **required** | the TREK versions this plugin supports, as a semver **range**: `">=3.4.0 <4.0.0"` (what the scaffold writes). Must be *satisfiable* (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it — rejected). **Enforced at install and at activation** (see below); it is copied verbatim onto the registry entry, which is the entry's only compatibility field. Keep it **bounded** — an unbounded range claims support for TREK versions that do not exist yet. | | `author` | string | shown in the store. | | `description` | string | one-line summary for the store. | | `icon` | string | lucide-react icon name (default `Blocks`); used for the page nav entry. | @@ -1139,47 +1155,84 @@ guard optional `ctx.*` namespaces. | `oauth` | `{ initPath, callbackPath }` for OAuth flows. | **Page nav:** the host builds a page plugin's nav entry from the top-level `name` -and `icon`. `create-trek-plugin` also scaffolds a `capabilities.nav` block, but the -installed-manifest parser only consumes `capabilities.widget` — set `name`/`icon` -to control the nav entry. +and `icon` — the installed-manifest parser only consumes `capabilities.widget`, so +there is nothing else to set. `icon` must be a real lucide name: TREK resolves it at +render time and silently falls back to `Blocks`, which makes a typo invisible locally, +so `validate` rejects one. See [[Plugin Permissions|Plugin-Permissions]] for the full permission model. ## The `trek-plugin` CLI Run `npx trek-plugin-sdk` **with no command** in a terminal and you get an -interactive menu (create / dev / validate / pack / publish, with signing and -registry-entry commands under **Advanced…**); it just picks which command to run, -then that command prompts for whatever it needs. Pass a command explicitly to skip -the menu (and for scripts/CI). +interactive menu — create / dev / status / shot / publish, with validate, pack, +signing and the registry-entry commands under **Advanced…** It picks which command to +run and asks which plugin directory to run it against; that command then prompts for +whatever else it needs. Pass a command explicitly to skip the menu (and for scripts and +CI). `trek-plugin help ` — or `trek-plugin --help` — prints a full +page for any command. -Author commands (from `trek-plugin-sdk`): +**The path is four commands**, and the other nine are steps one of them already does: ```bash -# 1. Manifest + layout checks (a subset of the registry CI — CI additionally -# verifies the GitHub release exists, the artifact sha256, and the README -# over the network). -trek-plugin validate [dir] - -# 2. Build plugin.zip in the installer's exact layout. Prints sha256 + byte size, -# refuses native binaries, enforces the same size limits (25MB/file, 50MB total). -# Ships trek-plugin.json, README.md, LICENSE(.md), package.json + server/ + client/. -# docs/ is intentionally NOT shipped — the store fetches docs/screenshot.png -# from your repo. --json prints a machine-readable result. -trek-plugin pack [dir] [--out plugin.zip] [--json] - -# 3. Emit the ready-to-PR registry entry: commitSha (resolved from the git tag), -# downloadUrl, sha256, size and minTrekVersion (derived from the manifest -# 'trek' range) all computed for you. --merge prepends a new version onto an -# existing entry (the update case, kept newest-first). -trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge entry.json] [--out file] - -# 4. One shot: pack -> create the GitHub release (via gh) -> print the entry. -trek-plugin release [dir] --repo owner/name --tag vX.Y.Z +trek-plugin create [name] [--type integration|page|widget|trip-page] +trek-plugin dev [dir] [--port 4317] +trek-plugin status [dir] +trek-plugin publish [dir] --repo owner/name --tag vX.Y.Z [--sign] ``` -To publish, open a PR that adds the emitted JSON as -`registry/plugins/.json` in the TREK-Plugins registry. +`status` is the one to reach for when you are not sure what to do next. It runs every +gate the TREK-Plugins registry enforces that can be answered **without a network** — +which is nearly all of them — and prints the whole journey as a checklist grouped by +stage (Manifest, Code, Docs, Release, Repo), with the one command to run next. It never +exits non-zero: it is for orientation, not for gating. + +```bash +# The gate. The SAME checks as `status`, but it exits 1. This is the form for CI. +trek-plugin validate [dir] + +# Build plugin.zip in the installer's exact layout. Prints sha256 + byte size, +# refuses native binaries, enforces the same size limits (25MB/file, 50MB total). +# Ships trek-plugin.json, README.md, LICENSE(.md), package.json + server/ + client/. +# docs/ is intentionally NOT shipped — the store fetches docs/screenshot.png from +# your repo. It refuses a plugin that could not LOAD, but deliberately does not +# enforce the publish gates (an unwritten README, a missing screenshot) — packing is +# how you sideload a plugin to try it locally. +trek-plugin pack [dir] [--out plugin.zip] [--json] + +# Boot the dev server, render the plugin in the themed /preview frame, and write a +# 1600x900 docs/screenshot.png. Needs Playwright (not an SDK dependency — it ships a +# browser): npm i -D playwright && npx playwright install chromium +trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve] + +# Ed25519 signing. keygen once; `publish --sign` is the usual way to use the key. +trek-plugin keygen [--key file] +trek-plugin sign [zip] [--key file] + +# Emit the ready-to-PR registry entry: commitSha (resolved from the git tag), +# downloadUrl, sha256, size, and the manifest's `trek` range verbatim — plus +# requiredAddons and pluginDependencies, which the registry parity-checks against the +# manifest. --merge prepends a new version onto an existing entry (the update case, +# kept newest-first). +trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge entry.json] [--out file] + +# The registry checks that genuinely need the network: the tag resolves to the commit +# the entry pins, the released artifact downloads and hashes to the pinned sha256, the +# id is not bound to another GitHub owner, and an update does not drop or rotate a +# signing key you already published under. `publish` runs this for you. +trek-plugin preflight [dir] --repo owner/name --tag vX.Y.Z [--entry file.json] [--all] + +# Pack -> cut the GitHub release -> print the entry, without opening the registry PR. +trek-plugin release [dir] --repo owner/name --tag vX.Y.Z [--sign] [--merge entry.json] + +# Fork the registry, write registry/plugins/.json, open the PR. Needs `gh`. +trek-plugin submit [dir] --repo owner/name --tag vX.Y.Z [--registry o/n] [--draft] +``` + +`publish` chains those last steps in the order that matters: **check → pack → release → +preflight → submit**. The local gates run *before* anything is tagged or released, +because a GitHub release is effectively immutable — the registry pins its sha256. See +[[Publishing a Plugin|Plugin-Publishing]]. ## Registry & publishing diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md index a08be6e03..c14d219db 100644 --- a/wiki/Plugin-Publishing.md +++ b/wiki/Plugin-Publishing.md @@ -10,16 +10,33 @@ the mechanical work — you rarely hand-type a hash, size, commit, or JSON field ## The short version — one command -Commit and push your plugin to its public GitHub repo, then: +Get the plugin green locally (`trek-plugin status` will tell you what is left), commit +and push it to its public GitHub repo, then: ```bash npx trek-plugin-sdk publish --repo you/trek-plugin-flight-tracker --tag v1.0.0 ``` -`publish` runs the whole release: **pack** → **tag + GitHub release** → -**preflight** (the registry CI checks, locally) → **open the registry PR**. If -preflight finds a problem it stops *before* submitting, so a broken entry never -becomes a doomed PR. It prints the PR URL at the end. Sign it with `--sign`. +`publish` runs the whole release, in five steps and in **this order**: + +1. **check** — every registry gate that can be checked locally +2. **pack** — build `plugin.zip` +3. **release** — git tag, push, and cut the GitHub release with the artifact attached +4. **preflight** — the gates that need the tag and the release to exist +5. **submit** — open the PR against the registry + +The order is the point. A GitHub release is effectively immutable: the registry pins +its sha256, so overwriting the bytes breaks the checksum for everyone who already +installed that version. So the local gates run **first** — if one fails, nothing is +packed, tagged, pushed or released, and you can fix it and re-run against the same +version. (Before this, the checks ran *after* the release was cut, and an author who +failed them had burned their `v1.0.0` tag on an unwritten README.) + +It prints the PR URL at the end. In a terminal it **offers to sign** the release (and +creates the key if you have none); scripts and CI are never prompted and pass `--sign`. +`--no-checks` skips step 1 and +`--no-preflight` skips step 4 — escape hatches for a re-run, never right on a first +publish. It needs `git` and an authenticated `gh`. The individual steps below still exist for when you want one by hand — `release` (pack → GitHub release → entry), `preflight`, and `submit` (opens the PR) — or @@ -44,19 +61,55 @@ A publishable plugin has, at the repo root: - `README.md` — filled in, with a real screenshot (the quality gate is strict — see below) - `docs/screenshot.png` — the store card image, committed to the repo -## 2. Validate +What `create` gives you runs and packs, but it does **not** pass `validate`: the README +is a template and there is no screenshot. Those are the two things only you can write. + +## 2. Get it green locally ```bash -npx trek-plugin-sdk validate . +npx trek-plugin-sdk status # where am I? what's left? — never fails +npx trek-plugin-sdk validate . # the same checks, but it exits non-zero ``` -`validate` runs the manifest checks plus a light layout/README sanity pass: -it fails if `trek-plugin.json` is invalid or `server/index.js` is missing, and -warns if the directory name doesn't match the plugin `id`, the README has no -screenshot, or the README still contains scaffold placeholders. This is a -**subset** of what CI runs — CI additionally verifies the release, the artifact's -SHA-256, and the README over the network — so a clean local run predicts a clean -CI run but doesn't replace it. +`status` and `validate` run the **same registry gates** at two different depths. +`status` prints them as a checklist grouped by stage (Manifest, Code, Docs, Release, +Repo) and names the one command to run next; it is for orientation, so it never fails. +`validate` is the gate — same checks, exit code — and is the form for scripts and CI. + +Between them they now catch, **offline**, nearly everything the registry rejects for: + +- an `icon` that isn't a real lucide name (TREK falls back to `Blocks` silently, so a + typo is invisible locally — but CI rejects it) +- a README missing any of the four required sections, still carrying scaffold + placeholders, or under **400 characters** of real prose +- a screenshot that doesn't resolve to a file on disk (not just a link in the README — + the file has to be there) +- a permission your manifest declares but the README never explains +- a `name`, `description` or `author` outside the registry's length limits, or a name + that mixes Latin with Cyrillic/Greek look-alikes (a homoglyph spoof) +- an `egress[]` host with no matching `http:outbound:` permission — TREK builds + the network allow-list and the iframe CSP from those permissions only and never reads + `egress[]`, so such a host is silently unreachable at runtime + +Only **four** gates genuinely need the network, and they all need the release to exist: +that the tag resolves to the commit the entry pins, that the released artifact downloads +and hashes to the pinned sha256 (and verifies against your key), that your plugin id +isn't bound to another GitHub owner, and that an update doesn't drop or rotate a signing +key you already published under. Those run in `preflight` — step 4 of `publish`. + +### Capture the screenshot + +```bash +npm i -D playwright && npx playwright install chromium # once — not an SDK dependency +npx trek-plugin-sdk shot # --dark for the dark theme +``` + +`shot` boots the dev server, renders your plugin in the same themed frame TREK uses, +and writes a 1600×900 `docs/screenshot.png`. An `integration` plugin has no UI to +render, so `shot` can't help — screenshot the TREK surface your plugin changes instead. + +Commit the screenshot. The registry resolves it **at the pinned commit**, so an image +that exists only in your working tree fails CI even though `status` was green. ## 3. Pack @@ -66,8 +119,13 @@ npx trek-plugin-sdk pack . --out dist.zip # custom output path npx trek-plugin-sdk pack . --json # machine-readable result ``` -`pack` validates first, then builds `plugin.zip` in the installer's exact layout -and prints the **sha256** and **size** you'd otherwise compute by hand. It ships +`pack` builds `plugin.zip` in the installer's exact layout and prints the **sha256** +and **size** you'd otherwise compute by hand. It refuses to pack a plugin that could +not *load* — a broken manifest, a missing `server/index.js`, a native binary — but it +deliberately does **not** enforce the publish gates (an unwritten README, a missing +screenshot), because packing is also how you sideload a plugin into a local TREK to try +it, and the docs only have to be there when you ship. `validate` is what gates those. +It ships only what the runtime needs — `trek-plugin.json`, `README.md`, `LICENSE`, `package.json`, and the `server/` and `client/` trees — and drops `node_modules`, `.git`, source maps and `.ts` sources. It **refuses native binaries** @@ -129,21 +187,27 @@ npx trek-plugin-sdk release . --repo you/trek-plugin-flight-tracker --tag v1.0.0 entry to stdout. It accepts `--out`, `--notes`, `--commit`, and `--merge`. (It requires the `gh` CLI, authenticated.) -## 6. Preflight — run the CI checks locally +## 6. Preflight — the checks that need the release to exist -Before you open the PR, run the exact registry CI checks against your pushed +Before you open the PR, run the registry's remaining CI checks against your pushed release, so you catch what CI would reject without a review round-trip: ```bash npx trek-plugin-sdk preflight --repo you/trek-plugin-flight-tracker --tag v1.0.0 ``` -`preflight` mirrors both CI scripts over the network: the tag resolves to the -pinned `commitSha`, the manifest at that commit matches the entry, the released -artifact's **sha256 + size** match and it carries **no native binaries**, and the -README passes the quality gate (required sections, real prose, a resolving -screenshot, permission parity). It also accepts `--entry ` to check a -hand-written entry. A green preflight predicts a green CI. +`preflight` runs everything `validate` runs, plus the four gates that genuinely need +the network: the tag resolves to the pinned `commitSha`; the manifest **and the +README** at that commit match the entry and pass the quality gate; the released +artifact downloads, hashes to the pinned **sha256**, carries **no native binaries** and +verifies against your key; your plugin id is not bound to a different GitHub owner; and +an update does not drop or rotate a signing key you already published under. + +Re-grading the manifest and the README *at the commit* is not redundant with the local +pass: an author who writes the README and forgets to commit it has a green tree and a +red tag, and the tag is what CI grades. `--entry ` checks a hand-written +entry, `--all` checks every version rather than just the newest. A green preflight +predicts a green CI. ## 7. Open the registry PR @@ -175,7 +239,9 @@ whose floor disagrees with that range). ## What CI enforces CI runs `scripts/validate-entry.mjs` and `scripts/check-readme.mjs` on every -changed `registry/plugins/*.json`. +changed `registry/plugins/*.json`. Nearly every rule below is a pure function of your +`trek-plugin.json` and `README.md`, so `trek-plugin validate` checks it offline before +you have tagged anything — you should never learn any of this from CI. **Entry** (`validate-entry.mjs`): valid schema · `id` matches the filename and the slug pattern `^[a-z][a-z0-9-]{2,39}$` · your `id` is bound to your GitHub @@ -217,19 +283,21 @@ TREK verifies the signature offline (minisign / Ed25519, no external service) an pins your key on first install (trust-on-first-use): a later release signed with a different key is refused until an admin re-trusts it. -**The easy way — let the SDK do it** (dependency-free Ed25519, no minisign needed): +**The easy way — let the SDK do it** (dependency-free Ed25519, no minisign needed). +In a terminal, `publish` **proposes signing**: it explains the tradeoff, offers to +create the key if you have none, and signs. You never have to know `keygen` or +`--sign` exist. Scripts and CI are never prompted — they pass `--sign`: ```bash -npx trek-plugin-sdk keygen # once: writes ~/.trek-plugin/signing.key -npx trek-plugin-sdk release --repo you/repo --tag v1.2.0 --sign -npx trek-plugin-sdk submit --repo you/repo --tag v1.2.0 --sign +npx trek-plugin-sdk publish --repo you/repo --tag v1.2.0 --sign ``` `--sign` signs the exact artifact bytes and fills both `authorPublicKey` (entry) -and `signature` (version) for you; `submit --sign` even refuses to publish an -update signed with a *different* key than the one already listed, so you can't -lock yourself out by accident. **Back up `~/.trek-plugin/signing.key`** — losing -it means you can't ship signed updates. +and `signature` (version) for you. If the plugin was already published signed, +`publish` **refuses an unsigned release at step 1** — before anything is packed, +tagged or released — because a GitHub release is immutable and learning this at +step 4 would have burned the tag. **Back up `~/.trek-plugin/signing.key`** — +losing it means you can't ship signed updates. **By hand with minisign**, if you prefer: @@ -259,14 +327,21 @@ next to its `sha256`: } ``` -Signing is **opt-in**: an entry without `authorPublicKey`/`signature` installs on -`sha256` alone. But once a plugin has shipped signed, an *unsigned* update is -refused — don't drop the signature between versions. +Signing is **opt-in**, and it is a one-way door you may walk through **late**. An +entry without `authorPublicKey`/`signature` installs on `sha256` alone; **going +unsigned → signed later breaks nobody**, because nothing is pinned until a signed +version installs — adding a key at v1.4.0 is a real option, not a lost cause. What +you can never do is go back: once a plugin has shipped signed, an *unsigned* update +is refused (`SIGNATURE_MISSING`) on every instance that already has it, and a key +*rotation* needs a registry maintainer override (`allow-key-change`) plus an admin +re-trust on each instance. So sign whenever you like — but back the key up. ## Updating -Bump `version` in the manifest, re-`pack`, cut a new `vX.Y.Z` release, then fold -the new version onto your existing entry with `--merge`: +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. By hand: re-`pack`, +cut a new `vX.Y.Z` release, then fold the new version onto your existing entry with +`--merge`: ```bash npx trek-plugin-sdk entry --repo you/trek-plugin-flight-tracker --tag v1.1.0 \