mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(sdk) rework the sdk helpers and DX/UX
This commit is contained in:
+94
-25
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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 `` 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 };
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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(', ') + '.';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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') };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
* ``, 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('', '') }));
|
||||
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\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('')).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([]);
|
||||
});
|
||||
});
|
||||
@@ -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` +
|
||||
'\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',
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user