fix(sdk) rework the sdk helpers and DX/UX

This commit is contained in:
jubnl
2026-07-13 18:41:22 +02:00
parent c3fb35f4ca
commit 6c8fcb29de
30 changed files with 3808 additions and 528 deletions
+94 -25
View File
@@ -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 <command>` (or the short `trek-plugin`
bin if you install the package):
bin if you install the package). `trek-plugin help <command>` — or
`trek-plugin <command> --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
+65
View File
@@ -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<string, unknown> | 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<string, unknown>;
}
} 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<CheckContext> & { dir?: string }): CheckContext {
const files = new Set<string>();
return {
dir: over.dir ?? '/plugin',
exists: (rel: string) => files.has(rel),
...over,
};
}
+71
View File
@@ -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<CheckReport> {
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';
+386
View File
@@ -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<string, string> {
const h: Record<string, string> = { '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<string, string> = { 'User-Agent': 'trek-plugin-preflight' }): Promise<string | null> {
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<string, unknown>;
try {
m = JSON.parse(text) as Record<string, unknown>;
} 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<string, { boundOwner?: string }> };
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,
];
+630
View File
@@ -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:<host>` 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:<host> 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:<host>` 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:<host>) 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 = /<select(?![^>]*\bdata-trek-native\b)(\s|>)/i.test(c.clientHtml);
const usesKit = c.clientHtml.includes('<!-- trek:ui -->') || c.clientHtml.includes('data-trek-ui');
return rawSelect && !usesKit
? fail(
'a raw <select> with no design kit',
'Native dropdowns are drawn by the OS and cannot match TREK. Add the <!-- trek:ui --> marker so selects are\n' +
'auto-styled, or mark a field data-trek-native to opt out on purpose.',
)
: pass();
},
};
// ── docs ────────────────────────────────────────────────────────────────────────
// The stage where nearly every first publish dies. All four of these are registry gates.
const docsReadmePresent: OfflineCheck = {
id: 'docs.readme-present',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README.md exists',
run: (c) => (c.readme === undefined ? fail('README.md is missing', 'The registry requires one at the repo root.') : pass()),
};
const docsReadmeSections: OfflineCheck = {
id: 'docs.readme-sections',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has all four required sections',
run: (c) => {
if (c.readme === undefined) return skip();
const missing = missingSections(c.readme);
return missing.length
? fail(
`missing ${missing.map((s) => `"## ${s}"`).join(', ')}`,
`The registry requires exactly these four headings: ${REQUIRED_SECTIONS.map((s) => `## ${s}`).join(', ')}.`,
)
: pass();
},
};
const docsReadmePlaceholders: OfflineCheck = {
id: 'docs.readme-placeholders',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has no template placeholders left',
run: (c) => {
if (c.readme === undefined) return skip();
const hits = placeholders(c.readme);
return hits.length
? fail(
hits.map((h) => `"${h}"`).join(', '),
'This is scaffold text you have not replaced yet. The registry rejects it — it is the signal that a README\nwas never actually written.',
)
: pass();
},
};
const docsReadmeProse: OfflineCheck = {
id: 'docs.readme-prose',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has enough written content',
run: (c) => {
if (c.readme === undefined) return skip();
const n = proseLength(c.readme);
return n < MIN_PROSE_CHARS
? fail(
`${n}/${MIN_PROSE_CHARS} chars of prose`,
'Headings, tables, code blocks, links and images do not count — the registry strips them and measures what is\n' +
'left. It has to be sentences: what the plugin does, why someone would install it, how to set it up.',
)
: pass(`${n} chars`);
},
};
/**
* A screenshot that RESOLVES.
*
* The old check regexed the README for an image *link* and passed if it found one. The scaffold
* writes `![screenshot](./docs/screenshot.png)` and never creates docs/ — so the old check passed
* on a link to a file that does not exist, and the author learned the truth from CI, after the
* release was immutable. Resolve the path on disk instead.
*/
const docsScreenshot: OfflineCheck = {
id: 'docs.screenshot',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README screenshot resolves to a real image',
run: (c) => {
if (c.readme === undefined) return skip();
const imgs = images(c.readme);
if (!imgs.length) {
return fail(
'no images in the README',
'The store card shows this image, and the registry rejects a plugin without one. Capture a 16:9 shot\n' +
'(1600×900 is ideal — the card crops the edges) and commit it as docs/screenshot.png.',
'trek-plugin shot',
);
}
const remote = imgs.filter((u) => /^https?:\/\//.test(u));
const local = imgs.filter((u) => !/^https?:\/\//.test(u));
const resolved = local.filter((u) => c.exists(u.replace(/^\.?\//, '')));
if (resolved.length) return pass(resolved[0]);
// Nothing local resolves. A remote URL might still satisfy CI, but we cannot know offline —
// don't claim a pass we haven't earned, and don't claim a failure we can't prove.
if (remote.length) return pass(`${remote.length} remote image(s) — preflight verifies they resolve`);
const missing = local.filter((u) => !c.exists(u.replace(/^\.?\//, '')));
return fail(
`${missing.join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist`,
'The README links this image but the file is not there, so the store card would be a broken image and the\n' +
'registry rejects it. Capture a 16:9 shot (1600×900 is ideal — the card crops the edges).',
'trek-plugin shot',
);
},
};
const docsPermissionsExplained: OfflineCheck = {
id: 'docs.permissions-explained',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'Every permission is explained in the README',
run: (c) => {
if (c.readme === undefined || !c.manifest) return skip();
const perms = arr(c.manifest.permissions);
if (!perms.length) return skip();
const undocumented = undocumentedPermissions(c.readme, perms);
return undocumented.length
? fail(
undocumented.join(', '),
'The registry requires every permission string you request to appear verbatim in the README, so a user\n' +
'installing your plugin can read why you need it. Add a row per permission to the "## Permissions" table.',
)
: pass(`${perms.length} permission${perms.length === 1 ? '' : 's'}`);
},
};
// ── release ─────────────────────────────────────────────────────────────────────
// These need an artifact or an entry, so they skip on the plain `status` path.
const releaseNoNativeBinaries: OfflineCheck = {
id: 'release.no-native-binaries',
blocks: 'artifact',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Artifact contains no native binaries',
run: (c) => {
if (!c.zipBytes) return skip();
let names: string[] = [];
try {
names = listZipNames(c.zipBytes);
} catch {
return fail('could not read the artifact as a zip');
}
const native = names.filter((n) => NATIVE_RE.test(n));
return native.length
? fail(
native.slice(0, 5).join(', '),
'Native modules (.node / binding.gyp / prebuilds) are forbidden in v1 — TREK runs plugins under Node\'s\n' +
'permission model in a child process and cannot sandbox native code.',
)
: pass(`${names.length} files`);
},
};
/**
* Manifest ↔ entry parity, offline.
*
* The registry re-derives the manifest from the pinned commit and refuses any entry that
* disagrees with it. When `entry.ts` builds the entry this is true by construction — but not on
* the `--merge` or `preflight --entry <file>` paths, where the entry can be hand-edited. And it
* was NOT true by construction until recently: buildEntry silently omitted requiredAddons and
* pluginDependencies, so any plugin that actually used one produced an entry CI would reject.
*/
const releaseEntryParity: OfflineCheck = {
id: 'release.entry-parity',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Registry entry matches the manifest',
run: (c) => {
if (!c.entry || !c.manifest) return skip();
const m = c.manifest;
const v = c.entry.versions[0];
if (!v) return fail('entry has no versions');
const problems: string[] = [];
if (str(m.id) !== c.entry.id) problems.push(`• manifest id "${str(m.id)}" != entry id "${c.entry.id}"`);
if (str(m.version) !== v.version) problems.push(`• manifest version "${str(m.version)}" != entry version "${v.version}"`);
if (str(m.type) !== c.entry.type) problems.push(`• manifest type "${str(m.type)}" != entry type "${c.entry.type}"`);
const mApi = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
if (mApi !== v.apiVersion) problems.push(`• manifest apiVersion ${mApi} != entry ${v.apiVersion}`);
if (str(m.trek) !== v.trek) problems.push(`• manifest trek "${str(m.trek)}" != entry trek "${v.trek}"`);
if ((m.operatorEgress === true) !== (v.operatorEgress === true)) {
problems.push(`• manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`);
}
const normAddons = (a: unknown) => [...arr(a)].sort();
if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
problems.push(`• manifest requiredAddons [${normAddons(m.requiredAddons)}] != entry [${normAddons(v.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))) {
problems.push(`• manifest pluginDependencies [${normDeps(m.pluginDependencies)}] != entry [${normDeps(v.pluginDependencies)}]`);
}
return problems.length
? fail(
`${problems.length} mismatch${problems.length === 1 ? '' : 'es'}`,
problems.join('\n') +
'\nThe registry re-reads your manifest at the pinned commit and refuses an entry that disagrees with it.',
)
: pass();
},
};
const releaseArtifactHash: OfflineCheck = {
id: 'release.artifact-hash',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Entry sha256/size match the packed artifact',
run: (c) => {
if (!c.zipBytes || !c.entry) return skip();
const v = c.entry.versions[0];
if (!v) return skip();
const sha = createHash('sha256').update(c.zipBytes).digest('hex');
const problems: string[] = [];
if (sha !== v.sha256) problems.push(`• artifact sha256 ${sha.slice(0, 12)}… != entry ${v.sha256.slice(0, 12)}…`);
if (c.zipBytes.length !== v.size) problems.push(`• artifact is ${c.zipBytes.length}B, entry declares ${v.size}B`);
return problems.length
? fail(
`${problems.length} mismatch${problems.length === 1 ? '' : 'es'}`,
problems.join('\n') + '\nThe entry was built from different bytes than the artifact on disk — re-pack, then rebuild the entry.',
)
: pass();
},
};
/**
* A key without a signature, or a signature without a key.
*
* Passes the registry's JSON schema — the two fields sit at different levels, so no schema can
* relate them — and is then REFUSED by TREK at install time. A half-signed entry is dead on
* arrival, and it is a pure function of the entry, so there is no reason to wait for the network
* to say so.
*/
const releaseSignatureShape: OfflineCheck = {
id: 'release.signature-shape',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Signature and public key are both present or both absent',
run: (c) => {
if (!c.entry) return skip();
const problems = checkSignatureShape(c.entry);
return problems.length
? fail(
`${problems.length} problem${problems.length === 1 ? '' : 's'}`,
problems.map((p) => '• ' + p).join('\n') + '\nTREK refuses to install a half-signed entry, so this one could never be installed.',
)
: pass(c.entry.authorPublicKey ? 'signed' : 'unsigned');
},
};
/** Every offline check, in the order `status` prints them. */
export const OFFLINE_CHECKS: OfflineCheck[] = [
manifestPresent,
manifestValid,
manifestEntryFields,
manifestIcon,
manifestTrekRange,
manifestIdMatchesDir,
manifestAddonsKnown,
manifestNoEmoji,
codeServerEntry,
codeEgressReachable,
codeEgressDeclared,
codeDesignKit,
docsReadmePresent,
docsReadmeSections,
docsReadmePlaceholders,
docsReadmeProse,
docsScreenshot,
docsPermissionsExplained,
releaseSignatureShape,
releaseNoNativeBinaries,
releaseEntryParity,
releaseArtifactHash,
];
export type { CheckContext };
+81
View File
@@ -0,0 +1,81 @@
/**
* The README quality gate, as pure functions of the markdown.
*
* These are a LINE-FOR-LINE port of TREK-Plugins' scripts/check-readme.mjs. They live apart
* from the checks that call them because the same gate runs twice, against two different
* READMEs, and both must agree with CI:
*
* - offline, against README.md in the WORKING TREE (`status` / `validate`)
* - over the network, against README.md at the PINNED COMMIT (`preflight`)
*
* The two differ more often than you would think — an author fills the README in and forgets
* to commit it, and the tree is green while the tag CI grades is not. Keeping one implementation
* means the offline pass can never be more lenient than the network one; only more current.
*
* If you change a rule here, change scripts/check-readme.mjs to match, or you have built a
* false green. test/checks-parity.test.ts asserts the two agree.
*/
/** Every section CI demands, matched case-insensitively as a substring of a heading. */
export const REQUIRED_SECTIONS = ['What it does', 'Screenshots', 'Permissions', 'Setup'];
/** Template prose that means the author never edited the scaffold. */
export const PLACEHOLDER_PATTERNS = [
/\{\{[^}]*\}\}/,
/\bREPLACE_ME\b/i,
/\bDescribe (what|the)\b/i,
/\byour-name\/trek-plugin/i,
];
export const MIN_PROSE_CHARS = 400;
/** Which of REQUIRED_SECTIONS have no matching heading. */
export function missingSections(md: string): string[] {
// The `(?:<!--.*)?` tail mirrors check-readme.mjs — it tolerates a trailing HTML comment
// on a heading line, which authors use to hide anchors.
const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*(?:<!--.*)?$/gm)].map((m) => m[1].toLowerCase());
return REQUIRED_SECTIONS.filter((want) => !headings.some((got) => got.includes(want.toLowerCase())));
}
/** The literal placeholder strings still present, e.g. ['Describe what']. */
export function placeholders(md: string): string[] {
const hits: string[] = [];
for (const re of PLACEHOLDER_PATTERNS) {
const hit = md.match(re);
if (hit) hits.push(hit[0]);
}
return hits;
}
/**
* The length of the author's actual WRITING, with everything that isn't prose stripped:
* comments, code fences, images, links, headings, table rows, then markdown punctuation.
*
* This is why a scaffold can't game the 400-char floor with a big permissions table — the
* table is stripped. It has to be sentences.
*/
export function proseLength(md: string): number {
return md
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+.*$/gm, '')
.replace(/^\s*\|.*$/gm, '')
.replace(/[#>*_`|-]/g, '')
.replace(/\s+/g, ' ')
.trim().length;
}
/** Every image src the README references — markdown and <img>, minus data: URIs (CI ignores those). */
export function images(md: string): string[] {
const mdImgs = [...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]);
const htmlImgs = [...md.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
return [...new Set([...mdImgs, ...htmlImgs])].filter((u) => !u.startsWith('data:'));
}
/** Manifest permissions that are never named anywhere in the README. CI rejects these. */
export function undocumentedPermissions(md: string, permissions: string[]): string[] {
const lower = md.toLowerCase();
return permissions.filter((p) => !lower.includes(p.toLowerCase()));
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Rendering a CheckReport for a human.
*
* Split from the checks themselves so the same outcomes can be a checklist you READ (`status`)
* or an exit code a script reads (`validate`) without either owning the rules.
*
* The thing that actually makes this useful is `nextStep()`. A list of failures tells an author
* what is wrong; it does not tell them what to DO, and with four things red the honest question
* is "which one first?". So the report always ends with exactly one command.
*/
import { STAGES, type CheckOutcome, type CheckReport } from './types.js';
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
/** Colour only when stderr is a terminal — piped output must stay clean for scripts. */
function colour(on: boolean) {
const c = (code: string) => (s: string) => (on ? code + s + RESET : s);
return { green: c(GREEN), red: c(RED), yellow: c(YELLOW), dim: c(DIM), bold: c(BOLD) };
}
function glyph(o: CheckOutcome): string {
if (o.status === 'skip') return '·';
if (o.status === 'pass') return '✓';
return o.severity === 'error' ? '✗' : '!';
}
/**
* The single next command.
*
* Preference order is deliberate: the FIRST failing error that names a command wins, and checks
* are ordered manifest → code → docs → release, so an author is always pointed at the earliest
* thing that is broken rather than the last. A plugin with a broken manifest and no screenshot
* should be told to fix the manifest; telling it to take a screenshot would be busywork.
*/
export function nextStep(report: CheckReport): { command?: string; hint: string } {
const firstWithCommand = report.errors.find((e) => e.next);
if (firstWithCommand?.next) {
return { command: firstWithCommand.next, hint: firstWithCommand.title };
}
const first = report.errors[0];
if (first) {
return { hint: `fix "${first.title}" above, then re-run \`trek-plugin status\`` };
}
return { command: 'trek-plugin publish --repo <owner/name> --tag v<x.y.z>', hint: 'everything checks out' };
}
export interface RenderOptions {
/** Show passing and skipped checks too. `status` does; `validate` only reports problems. */
verbose?: boolean;
colour?: boolean;
}
/** The full checklist, grouped by stage. This is what `status` prints. */
export function renderChecklist(report: CheckReport, opts: RenderOptions = {}): string {
const c = colour(opts.colour ?? false);
const lines: string[] = [];
for (const stage of STAGES) {
const outcomes = report.outcomes.filter((o) => o.stage === stage.id);
if (!outcomes.length) continue;
// A stage where everything skipped has nothing to say (e.g. `release` before you have packed).
const shown = opts.verbose ? outcomes : outcomes.filter((o) => o.status === 'fail');
if (!shown.length) continue;
lines.push('');
lines.push(' ' + c.bold(stage.title));
for (const o of shown) {
const g = glyph(o);
const paint = o.status === 'pass' ? c.green : o.status === 'skip' ? c.dim : o.severity === 'error' ? c.red : c.yellow;
const detail = o.detail ? c.dim(` — ${o.detail}`) : '';
const title = o.status === 'skip' ? c.dim(o.title) : o.title;
lines.push(` ${paint(g)} ${title}${detail}`);
// The `fix` is the part an author can act on, so it is never hidden behind a flag.
if (o.status === 'fail' && o.fix) {
for (const l of o.fix.split('\n')) lines.push(' ' + c.dim(l));
}
}
}
return lines.join('\n');
}
/** The plain one-problem-per-line form, for pipes and CI. `validate` prints this. */
export function renderPlain(report: CheckReport): string {
const lines: string[] = [];
for (const w of report.warnings) {
lines.push(`warning: ${w.title}${w.detail ? ` — ${w.detail}` : ''}`);
if (w.fix) for (const l of w.fix.split('\n')) lines.push(' ' + l);
}
for (const e of report.errors) {
lines.push(`error: ${e.title}${e.detail ? ` — ${e.detail}` : ''}`);
if (e.fix) for (const l of e.fix.split('\n')) lines.push(' ' + l);
}
return lines.join('\n');
}
/** "3 things block publishing." / "ready to publish." */
export function summaryLine(report: CheckReport): string {
const e = report.errors.length;
const w = report.warnings.length;
if (!e && !w) return 'ready to publish.';
const parts: string[] = [];
if (e) parts.push(`${e} thing${e === 1 ? '' : 's'} block${e === 1 ? 's' : ''} publishing`);
if (w) parts.push(`${w} warning${w === 1 ? '' : 's'}`);
return parts.join(', ') + '.';
}
+216
View File
@@ -0,0 +1,216 @@
/**
* The check registry: every gate the TREK-Plugins registry enforces, declared ONCE.
*
* The problem this solves: the registry's gates are, almost all of them, functions of two
* files that are sitting in the author's working directory (`trek-plugin.json`, `README.md`).
* But CI reads them over the network at a pinned commit, so we used to only check them over
* the network too — from `preflight`, which runs against a PUSHED TAG. By then the author has
* cut an immutable GitHub release, and the registry pins its sha256. Learning there that the
* README is 61 chars short is learning it one step too late.
*
* So each check declares what it NEEDS rather than where it runs:
*
* depth: 'offline' — a pure function of the working tree. Runs in `status`, `validate`,
* `preflight` and as `publish`'s first step. This is most of them.
* depth: 'network' — genuinely needs GitHub or the registry repo (a tag must resolve to a
* commit; an artifact must download and hash). Only four are really
* like this. They run in `preflight` and `publish`.
*
* One list, three depths. The contract inherited from preflight.ts still holds and now covers
* `validate` too: A GATE THE REGISTRY HAS AND THIS FILE DOESN'T IS A FALSE GREEN, and an
* author trusts a green. When TREK-Plugins' scripts/validate-entry.mjs or check-readme.mjs
* grows a rule, it grows one here — test/checks-parity.test.ts is what stops the two drifting.
*/
/**
* The journey, in the order an author walks it. `status` prints its checklist grouped by
* these, so they double as the answer to "where am I?".
*/
export type CheckStage = 'manifest' | 'code' | 'docs' | 'release';
export const STAGES: { id: CheckStage; title: string }[] = [
{ id: 'manifest', title: 'Manifest' },
{ id: 'code', title: 'Code' },
{ id: 'docs', title: 'Docs' },
{ id: 'release', title: 'Release' },
];
/** What a check needs in order to run. See the file header. */
export type CheckDepth = 'offline' | 'network';
/**
* 'error' fails `validate` (and blocks `publish`). 'warn' never fails a command — it is for
* things that are legal but almost always a mistake, where refusing would be presumptuous.
*
* The bar for 'error' is: THE REGISTRY WOULD REJECT THIS, or TREK WOULD REFUSE TO RUN IT.
* Anything softer is a warn. Getting this wrong in the lenient direction is a false green,
* which is the bug class this whole module exists to kill.
*/
export type CheckSeverity = 'error' | 'warn';
/**
* What a failure actually STOPS YOU DOING. Severity says how bad it is; this says when it bites.
*
* The distinction is not academic. `pack` validates before it zips, so without this axis an
* unwritten README would refuse to build an artifact — and packing is how you install a plugin
* into a local TREK to try it. Blocking the dev loop on a docs gate that only matters at publish
* time would be exactly the over-eager gatekeeping this rework exists to remove.
*
* 'artifact' — the plugin is broken. It cannot be built, loaded or installed. `pack` refuses.
* 'publish' — the plugin works, but the registry would reject it. `pack` allows it; `validate`
* and `publish` do not.
*
* A 'warn' never blocks anything, whatever it is tagged with.
*/
export type CheckBlocks = 'artifact' | 'publish';
export type CheckStatus = 'pass' | 'fail' | 'skip';
export interface CheckResult {
status: CheckStatus;
/** The specific, quantified finding: "61/400 chars of prose". Shown next to the title. */
detail?: string;
/** A human sentence: what to actually do about it. Shown indented under a failure. */
fix?: string;
/** The literal command that fixes this, e.g. `trek-plugin shot`. Drives "next →". */
next?: string;
}
export const pass = (detail?: string): CheckResult => ({ status: 'pass', detail });
export const skip = (detail?: string): CheckResult => ({ status: 'skip', detail });
export const fail = (detail: string, fix?: string, next?: string): CheckResult => ({
status: 'fail',
detail,
fix,
next,
});
/**
* Everything a check may read. Assembled once by `loadContext()` so that N checks don't do N
* reads of the same README, and so tests can hand-build a context without touching a disk.
*/
export interface CheckContext {
/** Absolute path to the plugin directory. */
dir: string;
/** Parsed `trek-plugin.json`, or undefined when it is missing or unparseable. */
manifest?: Record<string, unknown>;
/** Why the manifest is absent, when it is — "no trek-plugin.json" / a JSON parse error. */
manifestError?: string;
/** `README.md`, or undefined when missing. */
readme?: string;
/** `client/index.html`, when the plugin has one. */
clientHtml?: string;
/** Does a path relative to `dir` exist? The seam that lets tests fake a tree. */
exists: (rel: string) => boolean;
/**
* The packed artifact's bytes. Present only on the `pack`/`publish` path — checks that
* need it must `skip` when it is absent rather than fail, or `status` would demand a zip
* from an author who has not packed one and has no reason to.
*/
zipBytes?: Buffer;
/** The built registry entry — network checks only. */
entry?: RegistryEntry;
/** The registry repo to check owner binding + signing history against. Network checks only. */
registry?: string;
/** Check every version in the entry, not just the newest. Mirrors `preflight --all`. */
allVersions?: boolean;
}
/** The shape `entry.ts` builds and TREK-Plugins stores at registry/plugins/<id>.json. */
export interface RegistryEntry {
id: string;
name: string;
author: string;
description: string;
repo: string;
type: string;
icon?: string;
homepage?: string;
tags?: string[];
authorPublicKey?: string;
versions: RegistryEntryVersion[];
}
export interface RegistryEntryVersion {
version: string;
gitTag: string;
commitSha: string;
downloadUrl: string;
sha256: string;
trek: string;
size: number;
apiVersion: number;
nativeModules: false;
operatorEgress?: boolean;
signature?: string;
publishedAt: string;
requiredAddons?: string[];
pluginDependencies?: { id: string; version: string }[];
}
interface CheckBase {
/** Stable dotted id, e.g. 'readme.prose'. Tests and `--only` address checks by this. */
id: string;
stage: CheckStage;
severity: CheckSeverity;
/** When a failure bites. Defaults to 'publish' — most gates are the registry's, not the loader's. */
blocks?: CheckBlocks;
/** Imperative and specific — it reads as a checklist line: "README has all four sections". */
title: string;
}
/**
* An offline check is SYNCHRONOUS, and that is enforced by the type rather than by convention.
*
* It buys two things. It keeps `validate` synchronous, so `packPluginDir` — which validates
* before it zips — does not have to become async and drag `publish`/`release`/`entry` with it.
* And it makes "this check does no IO" a thing the compiler checks: an offline check that
* reaches for the network cannot await, so it cannot quietly become a network check.
*/
export interface OfflineCheck extends CheckBase {
depth: 'offline';
run(ctx: CheckContext): CheckResult;
}
export interface NetworkCheck extends CheckBase {
depth: 'network';
run(ctx: CheckContext): Promise<CheckResult>;
}
export type Check = OfflineCheck | NetworkCheck;
/** One check plus how it turned out. */
export interface CheckOutcome extends CheckResult {
id: string;
stage: CheckStage;
severity: CheckSeverity;
blocks: CheckBlocks;
title: string;
}
export interface CheckReport {
outcomes: CheckOutcome[];
/** Failures of severity 'error' — these block `validate` and `publish`. */
errors: CheckOutcome[];
/** Failures of severity 'warn' — reported, never fatal. */
warnings: CheckOutcome[];
/** No errors. Warnings do not affect this. */
ok: boolean;
}
export function summarize(outcomes: CheckOutcome[]): CheckReport {
const failed = outcomes.filter((o) => o.status === 'fail');
const errors = failed.filter((o) => o.severity === 'error');
const warnings = failed.filter((o) => o.severity === 'warn');
return { outcomes, errors, warnings, ok: errors.length === 0 };
}
/**
* The subset of a report that blocks a given action. `pack` asks for 'artifact' — it refuses to
* zip a plugin that could never load, but does not care that the README is still a stub.
*/
export function blocking(report: CheckReport, action: CheckBlocks): CheckReport {
if (action === 'publish') return report;
const errors = report.errors.filter((e) => e.blocks === 'artifact');
return { ...report, errors, ok: errors.length === 0 };
}
+136 -6
View File
@@ -15,6 +15,7 @@ import {
PERMISSION_CATALOG,
} from './ui.js';
import { KNOWN_ADDONS } from '../manifest.js';
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
import { notifySdkUpdate } from './update-notice.js';
/** This package's own version, for the scaffold's devDependency range. */
@@ -48,10 +49,38 @@ export interface ScaffoldOptions {
* channel is a plain `integration` that implements the `notificationChannel` hook.
*/
template?: 'blank' | 'notification-channel';
/** A lucide icon name (PascalCase). Falls back to a sensible default for the type. */
icon?: string;
/** Where a `widget` renders. Omitting it silently means 'sidebar'. */
slot?: WidgetSlot;
}
export const TEMPLATES = ['blank', 'notification-channel'] as const;
/** Mirrors the server's widget slots (and the manifest validator's). */
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
export type WidgetSlot = (typeof WIDGET_SLOTS)[number];
export const WIDGET_SLOT_HINTS: Record<WidgetSlot, string> = {
sidebar: 'The trip sidebar, alongside weather and countdown (the default)',
hero: 'The banner at the top of the trip',
'place-detail': "Inside a place's detail panel",
'day-detail': "Inside a day's detail panel",
'reservation-detail': "Inside a reservation's detail panel",
};
/**
* A defensible starting icon per plugin type. Not a guess at what the plugin DOES — it cannot know
* that — just a better default than every new plugin sharing the generic `Blocks` glyph.
*/
function defaultIconFor(type: string, isChannel: boolean): string {
if (isChannel) return 'BellRing';
if (type === 'widget') return 'LayoutGrid';
if (type === 'page') return 'BookOpen';
if (type === 'trip-page') return 'Map';
return 'Plug';
}
export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void {
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`);
@@ -92,7 +121,11 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
author: opts.author || 'Your Name',
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
type,
trek: '>=3.3.0 <4.0.0',
// 3.4.0, not 3.3.0. The `ctx` namespaces this scaffold is written against — ctx.meta,
// ctx.places, ctx.days, ctx.itinerary, ctx.costs, ctx.packing, ctx.files — only exist from
// 3.4.0. Claiming 3.3.0 lets the plugin install on a host where they are `undefined`, which
// fails at the first call with a TypeError instead of a clear "incompatible" at install.
trek: '>=3.4.0 <4.0.0',
nativeModules: false,
permissions: perms,
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
@@ -101,6 +134,10 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
requiredAddons: opts.requiredAddons ?? [],
pluginDependencies: opts.pluginDependencies ?? [],
};
// The store tile, the nav entry, the widget header and the admin row all render this. Omitting
// it is legal — TREK falls back to `Blocks` — but then every plugin that skipped it is the same
// grey square in the store, so the scaffold picks a real one rather than leaving it out.
manifest.icon = opts.icon && LUCIDE_ICON_NAMES.has(opts.icon) ? opts.icon : defaultIconFor(type, isChannel);
if (egress.length) manifest.egress = egress;
// A notification channel usually targets a SELF-HOSTED service, whose hostname the
// author cannot know at publish time. Declaring operatorEgress lets the admin add the
@@ -113,7 +150,12 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
// host ignores a manifest `routes`. A `page` plugin gets its nav entry
// automatically, so there's no `capabilities.nav` either; only `widget` carries a
// capability worth scaffolding.
if (type === 'widget') manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium' } };
// `slot` decides WHERE the widget renders, and omitting it silently means 'sidebar' — so a
// widget meant for a place's detail panel would quietly appear in the trip sidebar instead,
// with nothing anywhere to say why. Always write it.
if (type === 'widget') {
manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium', slot: opts.slot ?? 'sidebar' } };
}
if (isChannel) {
manifest.capabilities = { notificationChannel: { title: displayName } };
// The recipient's own credential. `scope: 'user'` is what makes it per-user, and
@@ -160,8 +202,44 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
}
// The README has always linked ./docs/screenshot.png, and the scaffold has never created the
// directory — so the very first thing an author was told to do had nowhere to go, and the only
// thing that noticed was the registry, after the release was immutable. Make the destination
// real, and say what belongs in it.
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
fs.writeFileSync(path.join(root, 'docs', 'README.md'), DOCS_README);
// Normalise line endings. The registry pins the artifact's sha256, and a CRLF checkout produces
// different bytes for the same source — so a Windows author re-packing the same commit gets a
// hash that no longer matches the entry they published.
fs.writeFileSync(path.join(root, '.gitattributes'), GITATTRIBUTES);
}
const DOCS_README = `Put \`screenshot.png\` here.
It is what the plugin's store card shows, and the registry REJECTS a plugin whose README has no
screenshot that resolves to a real image — so this is not optional.
- 16:9, 1600×900 is ideal. The card crops the edges, so leave some margin.
- Show the plugin doing its job, in context, inside TREK.
The quickest way: run \`trek-plugin dev\`, then \`trek-plugin shot\` in another terminal — it
renders your plugin in a themed TREK frame and writes docs/screenshot.png for you.
This directory is NOT shipped in plugin.zip; it lives in your repo, and the README links it at the
commit the registry pins.
`;
const GITATTRIBUTES = `# The registry pins the sha256 of your packed artifact. A CRLF checkout changes those bytes for
# the same source, so re-packing the same commit on Windows would produce a hash that no longer
# matches the entry you published. Pin the line endings and the artifact stays reproducible.
* text=auto eol=lf
*.png binary
*.jpg binary
*.zip binary
`;
const CHANNEL_JS = `// A TREK notification channel — runs in an isolated child process.
//
// TREK renders every notification into the recipient's language and hands it to you
@@ -436,11 +514,38 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
required: false,
});
// A widget with no slot silently renders in the sidebar. Ask, rather than let an author
// discover months later that `hero` was always available.
let slot: WidgetSlot | undefined;
if (type === 'widget') {
slot = await promptSelect<WidgetSlot>({
message: 'Where should the widget appear?',
initialValue: 'sidebar',
options: WIDGET_SLOTS.map((s) => ({ value: s, label: s, hint: WIDGET_SLOT_HINTS[s] })),
});
}
// The store tile, the nav entry and the admin row all render this. The registry REJECTS a name
// lucide does not have, so validate it here rather than at publish time.
const fallbackIcon = defaultIconFor(type, template === 'notification-channel');
const icon = await promptText({
message: 'Icon — a lucide name (see https://lucide.dev/icons)',
placeholder: fallbackIcon,
defaultValue: fallbackIcon,
validate: (v) => {
const name = (v ?? '').trim();
if (!name) return undefined; // the default is used
return LUCIDE_ICON_NAMES.has(name) ? undefined : `lucide has no icon called "${name}" — TREK would fall back to Blocks`;
},
}).then((v) => v.trim() || fallbackIcon);
note(
[
`id ${id}`,
`type ${type}`,
template !== 'blank' ? `template ${template}` : undefined,
slot ? `slot ${slot}` : undefined,
`icon ${icon}`,
`location ${dest}`,
`author ${author}`,
`permissions ${permissions.join(', ') || '(none)'}`,
@@ -457,7 +562,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
process.exit(0);
}
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template });
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template, icon, slot });
logSuccess(`Created ${dest}`);
if (!insideGitRepo(parentDir)) {
@@ -485,11 +590,35 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
}
}
const cd = path.relative(process.cwd(), dest) || dest;
outro(`Next steps:\n cd ${cd}\n npx trek-plugin-sdk dev`);
outro(nextStepsAfterCreate(dest));
return id;
}
/**
* What to do next, said ONCE.
*
* There used to be three of these — the wizard's outro, the `create` dispatch branch and the
* standalone `create-trek-plugin` bin — and they named three different next commands (`dev`,
* `dev`, `validate`). Whichever an author hit was a coin toss, and none of them mentioned that
* the plugin they had just been handed could not be published as-is.
*
* So it points at `dev` (the thing you actually want to do next) and at `status` (the thing that
* will tell you the truth about what is still missing), and it does not pretend the scaffold is
* finished.
*/
export function nextStepsAfterCreate(dest: string): string {
const cd = path.relative(process.cwd(), dest) || dest;
return [
'Next steps:',
` cd ${cd}`,
' trek-plugin dev run it locally, hot-reloaded',
' trek-plugin status what is left before you can publish',
'',
'The scaffold is not publishable yet — it needs a written README and a screenshot.',
'`status` lists exactly what, whenever you ask it.',
].join('\n');
}
// CLI entry
if (process.argv[1] && process.argv[1].endsWith('create.js')) {
notifySdkUpdate();
@@ -505,7 +634,8 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
}
try {
scaffold(name, type, process.cwd(), { template });
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`);
// The SAME message the wizard and `trek-plugin create` print. These three used to disagree.
console.log(nextStepsAfterCreate(path.join(process.cwd(), name)));
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
+14 -3
View File
@@ -392,7 +392,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
// hard-coded to 42 while the scaffold seeds trip 1, so the widget's first
// trek:invoke hit assertMember(42, …) and 500'd with RESOURCE_FORBIDDEN.
const previewTripId = Number(Object.keys(fx.trips ?? {})[0] ?? 1) || 1;
return send(200, preview(id, String(manifest.type), previewTripId), 'text/html; charset=utf-8');
// ?chrome=0 strips the dev toolbar and centres the plugin — what `trek-plugin shot`
// renders into docs/screenshot.png. ?theme=dark picks the dark palette.
const chrome = url.searchParams.get('chrome') !== '0';
const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme }), 'text/html; charset=utf-8');
}
// Static plugin UI at /ui (page/widget client bundle). The iframe loads
@@ -520,12 +524,19 @@ ${LIVE_RELOAD}`;
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
*/
function preview(id: string, type: string, tripId: number): string {
function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark' } = {}): string {
const maxW = type === 'widget' ? '440px' : '1000px';
// `trek-plugin shot` renders this page into docs/screenshot.png — the image the plugin store
// shows. The theme picker and the "runs sandboxed at an opaque origin" note are DEV furniture:
// useful while you build, absurd on a store card. So shot mode strips them and centres the
// plugin in the frame, and the shot is of the plugin rather than of the tool that drew it.
const chrome = opts.chrome !== false;
const dark = opts.theme === 'dark';
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
<style>
*{box-sizing:border-box}
${chrome ? '' : 'header,.hint{display:none!important}.stage{min-height:100vh;align-items:center;padding:0 20px}'}
body{margin:0;font:14px/1.5 system-ui,-apple-system,sans-serif;background:#f4f5f7;color:#111827}
body.dark{background:#0e0e11;color:#e5e7eb}
header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:inherit;z-index:2}
@@ -545,7 +556,7 @@ iframe{width:100%;border:0;background:transparent;min-height:120px;display:block
<header>
<strong>${id}</strong><span style="opacity:.5">· ${type} preview</span>
<span class="sp"></span>
<label>Theme <select id="theme"><option value="light">light</option><option value="dark">dark</option></select></label>
<label>Theme <select id="theme"><option value="light"${dark ? '' : ' selected'}>light</option><option value="dark"${dark ? ' selected' : ''}>dark</option></select></label>
<label>Accent <select id="accent"><option value="default">default</option><option value="indigo">indigo</option><option value="teal">teal</option><option value="rose">rose</option></select></label>
<label><input type="checkbox" id="rm"> reduce motion</label>
<label><input type="checkbox" id="nt"> no transparency</label>
+27
View File
@@ -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) {
+241
View File
@@ -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<string, CommandHelp> = {
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/<kind> 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 <owner/name> --tag <vX.Y.Z> [--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 <owner/name> --tag <vX.Y.Z> [--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 <owner/name> --tag <vX.Y.Z> [--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 <owner/name> --tag <vX.Y.Z> [--registry o/n] [--draft]',
body: `Forks the registry, writes registry/plugins/<id>.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 <owner/name> --tag <vX.Y.Z> [--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 <command>\` 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}`;
}
+14 -7
View File
@@ -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: '' },
+15 -3
View File
@@ -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[] = [];
+41 -204
View File
@@ -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<PreflightReport> {
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<string, string> = { '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<PreflightReport> {
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<string, unknown>;
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<void> {
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(/<!--[\s\S]*?-->/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(/<img[^>]+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)),
};
}
+77 -25
View File
@@ -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 };
}
+176
View File
@@ -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<boolean> =>
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<boolean> {
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<Chromium> }> {
type PW = { chromium: { launch: (o?: unknown) => Promise<Chromium> } };
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<unknown>;
emulateMedia(o: { colorScheme: 'light' | 'dark' }): Promise<void>;
waitForTimeout(ms: number): Promise<void>;
screenshot(o: { path: string; fullPage?: boolean }): Promise<unknown>;
}
interface Browser {
newPage(o?: unknown): Promise<Page>;
close(): Promise<void>;
}
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();
}
}
+147
View File
@@ -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<SigningState> {
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<string | undefined> {
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;
}
+87
View File
@@ -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') };
}
+161 -75
View File
@@ -2,38 +2,33 @@
/**
* `trek-plugin <command>` — 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<string, string>;
* instruction is worse than refusing it, so unknown flags are now an error.
*/
const COMMAND_FLAGS: Record<string, readonly string[]> = {
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 <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]';
const USAGE = 'usage: trek-plugin <create|dev|status|publish|...> (`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<void> {
// 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 <cmd> that command's own page
// trek-plugin <cmd> --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<string | undefined> {
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<void> {
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 <owner/name> --tag <vX.Y.Z>`.');
}
} 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 <owner/name> and --tag <vX.Y.Z>');
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);
}
}
+40 -95
View File
@@ -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<Record<string, unknown>>(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 <select> without the design kit falls back to the OS-drawn
// dropdown, which can't match TREK. The kit auto-upgrades selects, so nudge the
// author to inline it (or opt a field out on purpose).
const clientHtml = path.join(dir, 'client', 'index.html');
if (fs.existsSync(clientHtml)) {
const html = fs.readFileSync(clientHtml, 'utf8');
const rawSelect = /<select(?![^>]*\bdata-trek-native\b)(\s|>)/i.test(html);
const usesKit = html.includes('<!-- trek:ui -->') || html.includes('data-trek-ui');
if (rawSelect && !usesKit) {
warnings.push('client/index.html has a <select> but does not inline the design kit — native dropdowns will not match TREK. Add the <!-- trek:ui --> 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');
}
+205
View File
@@ -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/);
});
});
+260
View File
@@ -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> = {}): CheckContext {
const files = new Set<string>(['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<string, unknown>).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<string, unknown>).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:<host>`
* 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('<img src="shot.png">')).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([]);
});
});
+48
View File
@@ -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',
);
}
+108
View File
@@ -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);
});
});
+56 -11
View File
@@ -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<string, Buffer> {
@@ -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', () => {
+48
View File
@@ -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);
});
});
+52
View File
@@ -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> = {}): 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/);
});
});
+89 -36
View File
@@ -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:<host>` 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 <command>` — or `trek-plugin <command> --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/<id>.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/<id>.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
+113 -38
View File
@@ -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:<host>` 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 <file.json>` 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 <file.json>` 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 \