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

This commit is contained in:
jubnl
2026-07-13 18:41:22 +02:00
parent c3fb35f4ca
commit 6c8fcb29de
30 changed files with 3808 additions and 528 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Assemble a CheckContext from a plugin directory: read each file ONCE, hand the same
* strings to every check. Checks stay pure functions of this object, which is what makes
* them table-testable without a temp dir.
*/
import fs from 'node:fs';
import path from 'node:path';
import type { CheckContext, RegistryEntry } from './types.js';
function readIfPresent(p: string): string | undefined {
try {
return fs.readFileSync(p, 'utf8');
} catch {
return undefined;
}
}
export function loadContext(
dir: string,
extra: { zipBytes?: Buffer; entry?: RegistryEntry } = {},
): CheckContext {
const root = path.resolve(dir);
let manifest: Record<string, unknown> | undefined;
let manifestError: string | undefined;
const manifestRaw = readIfPresent(path.join(root, 'trek-plugin.json'));
if (manifestRaw === undefined) {
manifestError = `no trek-plugin.json in ${dir}`;
} else {
try {
// Tolerate a UTF-8 BOM. Windows editors add one, and a bare JSON.parse then fails with a
// cryptic "Unexpected token" pointing at an invisible character. Same rule as readJsonFile.
const text = manifestRaw.charCodeAt(0) === 0xfeff ? manifestRaw.slice(1) : manifestRaw;
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
manifestError = 'trek-plugin.json is not a JSON object';
} else {
manifest = parsed as Record<string, unknown>;
}
} catch (e) {
manifestError = 'trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : String(e));
}
}
return {
dir: root,
manifest,
manifestError,
readme: readIfPresent(path.join(root, 'README.md')),
clientHtml: readIfPresent(path.join(root, 'client', 'index.html')),
exists: (rel: string) => fs.existsSync(path.join(root, rel)),
zipBytes: extra.zipBytes,
entry: extra.entry,
};
}
/** Convenience for tests and for callers that already hold the strings. */
export function makeContext(over: Partial<CheckContext> & { dir?: string }): CheckContext {
const files = new Set<string>();
return {
dir: over.dir ?? '/plugin',
exists: (rel: string) => files.has(rel),
...over,
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* The check registry's public face: one list, run at the depth the caller can afford.
*
* runOffline(ctx) — `status`, `validate`, and `publish`'s first step. No network, no push.
* SYNCHRONOUS, which is what keeps `packPluginDir` (it validates before it
* zips) from having to become async and dragging publish/release with it.
* runAll(ctx) — `preflight` and `publish`: the offline set PLUS the handful of things
* that genuinely need GitHub.
*
* Nothing here decides how to PRINT a report — that is ./report.ts, so that `status` (a
* checklist you read) and `validate` (an exit code a script reads) can render the same
* outcomes differently without either owning the rules.
*/
import { OFFLINE_CHECKS } from './offline.js';
import { NETWORK_CHECKS } from './network.js';
import type { Check, CheckContext, CheckOutcome, CheckReport, CheckResult } from './types.js';
import { summarize } from './types.js';
export const ALL_CHECKS: Check[] = [...OFFLINE_CHECKS, ...NETWORK_CHECKS];
/**
* A check that throws is a bug in the CHECK, not a verdict on the plugin. Report it as a failure
* rather than letting it crash the command: an author who cannot run `status` at all is strictly
* worse off than one who sees "this check is broken".
*/
function threw(e: unknown): CheckResult {
return { status: 'fail', detail: 'check threw: ' + (e instanceof Error ? e.message : String(e)) };
}
const outcome = (check: Check, result: CheckResult): CheckOutcome => ({
id: check.id,
stage: check.stage,
severity: check.severity,
// Most gates are the REGISTRY's, not the loader's — they stop you publishing, not building.
// Only the handful that make a plugin unloadable opt into 'artifact'.
blocks: check.blocks ?? 'publish',
title: check.title,
...result,
});
/** Every gate that is a pure function of the working tree. */
export function runOffline(ctx: CheckContext): CheckReport {
return summarize(
OFFLINE_CHECKS.map((check) => {
try {
return outcome(check, check.run(ctx));
} catch (e) {
return outcome(check, threw(e));
}
}),
);
}
/** The offline set plus the network gates. Requires ctx.entry for the network half to do anything. */
export async function runAll(ctx: CheckContext): Promise<CheckReport> {
const outcomes: CheckOutcome[] = runOffline(ctx).outcomes;
for (const check of NETWORK_CHECKS) {
try {
outcomes.push(outcome(check, await check.run(ctx)));
} catch (e) {
outcomes.push(outcome(check, threw(e)));
}
}
return summarize(outcomes);
}
export { OFFLINE_CHECKS } from './offline.js';
export { NETWORK_CHECKS, DEFAULT_REGISTRY } from './network.js';
export * from './types.js';
export { loadContext, makeContext } from './context.js';
export { renderChecklist, renderPlain, summaryLine, nextStep } from './report.js';
+386
View File
@@ -0,0 +1,386 @@
/**
* The four things that genuinely cannot be known from the working tree.
*
* Everything else the registry enforces lives in ./offline.ts and fires before you have pushed
* anything. What is left here needs GitHub or the registry repo to answer:
*
* 1. does the tag exist, and does it point at the commit the entry pins?
* 2. do the released artifact's bytes hash to the sha256 the entry pins — and do they verify
* against the author's key?
* 3. is this plugin id already bound to a different GitHub owner?
* 4. was this plugin published SIGNED before, and is this update about to drop or rotate the key?
*
* (3) and (4) are new. The registry has always enforced them and `preflight` never did, which
* made a green preflight a promise it could not keep: a signing downgrade merges nowhere and
* BRICKS THE UPDATE for every instance that already has the plugin, and the author would only
* find out at review. See scripts/validate-entry.mjs, "signing-downgrade guard".
*
* The manifest and README are ALSO re-graded here, at the pinned commit rather than in the tree.
* That is not redundant with the offline pass: an author who fills the README in and forgets to
* commit it has a green tree and a red tag, and the tag is what CI grades.
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { listZipNames } from '../../zip.js';
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../verify-signature.js';
import type { NetworkCheck, CheckContext, RegistryEntry, RegistryEntryVersion } from './types.js';
import { pass, fail, skip } from './types.js';
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, images, undocumentedPermissions } from './readme.js';
export const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins';
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
/** A GitHub token lifts the anonymous API rate limit. `gh` already has one; borrow it. */
function ghToken(): string | undefined {
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
try {
return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
} catch {
return undefined;
}
}
function ghHeaders(): Record<string, string> {
const h: Record<string, string> = { 'User-Agent': 'trek-plugin-preflight', Accept: 'application/vnd.github+json' };
const t = ghToken();
if (t) h.Authorization = `Bearer ${t}`;
return h;
}
const rawUrl = (repo: string, sha: string, p: string): string =>
`https://raw.githubusercontent.com/${repo}/${sha}/${p.replace(/^\.?\//, '')}`;
/** The versions a network check grades: the newest by default, all of them with --all. */
function targetVersions(c: CheckContext): RegistryEntryVersion[] {
const vs = c.entry?.versions ?? [];
return c.allVersions ? vs : vs.slice(0, 1);
}
async function fetchText(url: string, headers: Record<string, string> = { 'User-Agent': 'trek-plugin-preflight' }): Promise<string | null> {
try {
const r = await fetch(url, { headers });
return r.ok ? await r.text() : null;
} catch {
return null;
}
}
// ── 1. the tag resolves to the pinned commit ────────────────────────────────────
const tagResolves: NetworkCheck = {
id: 'network.tag-resolves',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'Git tag exists and points at the pinned commit',
run: async (c) => {
if (!c.entry) return skip();
const problems: string[] = [];
for (const v of targetVersions(c)) {
try {
const r = await fetch(
`https://api.github.com/repos/${c.entry.repo}/git/refs/tags/${encodeURIComponent(v.gitTag)}`,
{ headers: ghHeaders() },
);
if (!r.ok) {
problems.push(`• ${v.version}: tag ${v.gitTag} not found on ${c.entry.repo} (${r.status}) — push the tag and cut the release`);
continue;
}
const ref = (await r.json()) as { object?: { sha?: string; type?: string } };
let sha = ref.object?.sha;
// An annotated tag's ref points at the TAG object, not the commit — deref it, or every
// annotated tag looks like a mismatch.
if (ref.object?.type === 'tag' && sha) {
const tr = await fetch(`https://api.github.com/repos/${c.entry.repo}/git/tags/${sha}`, { headers: ghHeaders() });
if (tr.ok) sha = ((await tr.json()) as { object?: { sha?: string } }).object?.sha;
}
if (sha && sha !== v.commitSha) {
problems.push(`• ${v.version}: tag ${v.gitTag} points at ${sha.slice(0, 8)}, entry pins ${v.commitSha.slice(0, 8)}`);
}
} catch (e) {
problems.push(`• ${v.version}: tag check failed: ${(e as Error).message}`);
}
}
return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass();
},
};
// ── 2. the manifest at the pinned commit matches the entry ──────────────────────
const manifestAtCommit: NetworkCheck = {
id: 'network.manifest-at-commit',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'Manifest at the pinned commit matches the entry',
run: async (c) => {
if (!c.entry) return skip();
const problems: string[] = [];
for (const v of targetVersions(c)) {
const text = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'trek-plugin.json'));
if (text === null) {
problems.push(`• ${v.version}: trek-plugin.json not found at ${v.commitSha.slice(0, 8)} — did you push your code?`);
continue;
}
let m: Record<string, unknown>;
try {
m = JSON.parse(text) as Record<string, unknown>;
} catch (e) {
problems.push(`• ${v.version}: trek-plugin.json at ${v.commitSha.slice(0, 8)} is not valid JSON: ${(e as Error).message}`);
continue;
}
const p = (msg: string) => problems.push(`• ${v.version}: ${msg}`);
if (m.id !== c.entry.id) p(`manifest id "${String(m.id)}" != entry id "${c.entry.id}"`);
if (m.version !== v.version) p(`manifest version "${String(m.version)}" != entry "${v.version}"`);
if (m.type !== c.entry.type) p(`manifest type "${String(m.type)}" != entry "${c.entry.type}"`);
const mApi = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
if (mApi !== v.apiVersion) p(`manifest apiVersion ${mApi} != entry ${v.apiVersion}`);
if (m.nativeModules === true) p('manifest declares nativeModules:true (forbidden in v1)');
if (typeof m.trek === 'string' && m.trek.trim() !== v.trek) p(`manifest trek "${m.trek.trim()}" != entry trek "${v.trek}"`);
if ((m.operatorEgress === true) !== (v.operatorEgress === true)) {
p(`manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`);
}
const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []);
if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
p('manifest requiredAddons != entry requiredAddons');
}
const normDeps = (d: unknown) =>
(Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort() : []);
if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) {
p('manifest pluginDependencies != entry pluginDependencies');
}
}
return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass();
},
};
// ── 3. the released artifact ────────────────────────────────────────────────────
const artifactMatches: NetworkCheck = {
id: 'network.artifact',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'Released artifact downloads, hashes and verifies',
run: async (c) => {
if (!c.entry) return skip();
const problems: string[] = [];
for (const v of targetVersions(c)) {
let bytes: Buffer;
try {
const r = await fetch(v.downloadUrl, { redirect: 'follow', headers: { 'User-Agent': 'trek-plugin-preflight' } });
if (!r.ok) {
problems.push(`• ${v.version}: artifact download failed (${r.status}) ${v.downloadUrl}`);
continue;
}
bytes = Buffer.from(await r.arrayBuffer());
} catch (e) {
problems.push(`• ${v.version}: artifact download failed: ${(e as Error).message}`);
continue;
}
// 4096B of slack mirrors the registry: GitHub can pad a release asset slightly.
if (bytes.length > v.size + 4096) problems.push(`• ${v.version}: artifact is ${bytes.length}B, entry declares ${v.size}B`);
const sha = createHash('sha256').update(bytes).digest('hex');
if (sha !== v.sha256) {
problems.push(
`• ${v.version}: sha256 mismatch — the release has ${sha.slice(0, 12)}…, the entry pins ${v.sha256.slice(0, 12)}…\n` +
' (did you re-pack after building the entry? the entry must be built from the EXACT bytes you uploaded)',
);
}
// The signature proves the bytes came from the AUTHOR's key; the sha256 only proves they
// are what the registry vouches for. TREK verifies this again at install and aborts on a
// mismatch, so a bad signature merged here is an entry nobody can install.
if (v.signature && c.entry.authorPublicKey) {
try {
if (!verifyAuthorSignature(bytes, v.signature, c.entry.authorPublicKey)) {
problems.push(`• ${v.version}: author signature does not verify — signed with a different key, or re-packed after signing?`);
}
} catch (e) {
if (e instanceof SignatureError) problems.push(`• ${v.version}: signature/key is malformed: ${e.message}`);
else throw e;
}
}
try {
const names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : [];
if (names.some((n) => NATIVE_RE.test(n))) {
problems.push(`• ${v.version}: artifact contains native binaries (.node / binding.gyp / prebuilds) — forbidden in v1`);
}
} catch {
/* not a zip we can read — the sha check above already speaks to the bytes */
}
}
return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass();
},
};
// ── 4. the README at the pinned commit ──────────────────────────────────────────
const readmeAtCommit: NetworkCheck = {
id: 'network.readme-at-commit',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'README at the pinned commit passes the registry gate',
run: async (c) => {
if (!c.entry) return skip();
const v = c.entry.versions[0];
if (!v) return skip();
const md = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'README.md'));
if (md === null) {
return fail(
`README.md not found at ${v.commitSha.slice(0, 8)}`,
'The registry reads the README at the COMMIT, not from your working tree. Commit and push it.',
);
}
const problems: string[] = [];
for (const s of missingSections(md)) problems.push(`• missing required section "## ${s}"`);
for (const p of placeholders(md)) problems.push(`• template placeholder still present: "${p}"`);
const n = proseLength(md);
if (n < MIN_PROSE_CHARS) problems.push(`• only ${n}/${MIN_PROSE_CHARS} chars of prose`);
// Resolve every image against the pinned commit. A relative path that exists in your tree
// but was never committed 404s here — which is exactly the failure this catches.
const imgs = images(md);
if (!imgs.length) problems.push('• no screenshot (the registry requires at least one real image)');
else {
let anyOk = false;
const reasons: string[] = [];
for (const src of imgs) {
const url = /^https?:\/\//.test(src)
? src.includes('github.com') && src.includes('/blob/')
? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
: src
: rawUrl(c.entry.repo, v.commitSha, src);
try {
const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } });
const ct = r.headers.get('content-type') || '';
if (r.ok && ct.startsWith('image/')) {
anyOk = true;
break;
}
reasons.push(`${src} → ${r.status} ${ct || 'no content-type'}`);
} catch {
reasons.push(`${src} → unreachable`);
}
}
if (!anyOk) problems.push('• no screenshot resolved to a real image:\n ' + reasons.join('\n '));
}
// Permission parity, against the manifest AT THE COMMIT (not the tree — they can differ).
const manifestText = await fetchText(rawUrl(c.entry.repo, v.commitSha, 'trek-plugin.json'));
if (manifestText) {
try {
const m = JSON.parse(manifestText) as { permissions?: unknown };
const perms = Array.isArray(m.permissions) ? m.permissions.map(String) : [];
const undoc = undocumentedPermissions(md, perms);
if (undoc.length) problems.push(`• permissions not explained in the README: ${undoc.join(', ')}`);
} catch {
/* manifest-at-commit already reports a broken manifest */
}
}
return problems.length
? fail(
`${problems.length} problem(s) at ${v.commitSha.slice(0, 8)}`,
problems.join('\n') +
`\nThese are graded at the pinned commit. If your working tree looks fine, you have uncommitted changes —\ncommit, push, and re-tag.\nRequired sections: ${REQUIRED_SECTIONS.map((s) => `## ${s}`).join(', ')}.`,
)
: pass();
},
};
// ── 5. owner binding (NEW — the registry has always enforced this) ───────────────
const ownerBinding: NetworkCheck = {
id: 'network.owner-binding',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'Plugin id is not bound to another GitHub owner',
run: async (c) => {
if (!c.entry) return skip();
const registry = c.registry ?? DEFAULT_REGISTRY;
const text = await fetchText(rawUrl(registry, 'main', 'OWNERS.json'));
if (text === null) return skip('could not read OWNERS.json — the registry will still check this');
let owners: { plugins?: Record<string, { boundOwner?: string }> };
try {
owners = JSON.parse(text) as typeof owners;
} catch {
return skip('OWNERS.json is unreadable — the registry will still check this');
}
const bound = owners.plugins?.[c.entry.id]?.boundOwner;
if (!bound) return pass('unclaimed id — it binds to you on merge');
const mine = c.entry.repo.split('/')[0];
return bound === mine
? pass(`bound to ${bound}`)
: fail(
`"${c.entry.id}" is bound to "${bound}", your repo is "${mine}"`,
'The registry binds a plugin id to the GitHub owner who first published it, so nobody can hijack an id.\n' +
'Either pick a different id, or ask a maintainer for the allow-owner-change label if this is a genuine transfer.',
);
},
};
// ── 6. signing-downgrade guard (NEW — a green here used to hide a bricked update) ─
const signingDowngrade: NetworkCheck = {
id: 'network.signing-downgrade',
stage: 'release',
depth: 'network',
severity: 'error',
title: 'Update does not drop or rotate a published signing key',
run: async (c) => {
if (!c.entry) return skip();
const registry = c.registry ?? DEFAULT_REGISTRY;
const text = await fetchText(rawUrl(registry, 'main', `registry/plugins/${c.entry.id}.json`));
if (text === null) return pass('not published yet — nothing to downgrade from');
let published: RegistryEntry;
try {
published = JSON.parse(text) as RegistryEntry;
} catch {
return skip('published entry is unreadable');
}
if (!published.authorPublicKey) return pass('previously published unsigned');
// TREK pins the author key on FIRST INSTALL (trust on first use). Once a plugin has shipped
// signed, an unsigned update — or one signed with a different key — is REFUSED on every
// instance that already has it. Merging that would not just fail; it would strand every
// existing user on the version they have.
const problems: string[] = [];
if (!c.entry.authorPublicKey) {
problems.push(
'• this plugin was published SIGNED, but this entry has no authorPublicKey.\n' +
' TREK refuses an unsigned update to a signed plugin — it would break the update for every existing install.\n' +
' Sign it: pass --sign (your key should be at ~/.trek-plugin/signing.key).',
);
} else if (c.entry.authorPublicKey !== published.authorPublicKey) {
problems.push(
'• this entry changes authorPublicKey. TREK refuses a key rotation until an admin re-trusts the plugin.\n' +
' Sign with your ORIGINAL key, or ask a maintainer for the allow-key-change label.',
);
} else {
// Every version must stay signed, not just the newest — TREK verifies whichever version it
// installs, so an unsigned older block is a landmine for a pinned install.
for (const v of c.entry.versions) {
if (!v.signature) problems.push(`• ${v.version} has no signature, but this is a signed plugin — TREK will refuse to install it`);
}
}
return problems.length ? fail(`${problems.length} problem(s)`, problems.join('\n')) : pass('signed with the published key');
},
};
/** Every network check, in the order `preflight` runs them. */
export const NETWORK_CHECKS: NetworkCheck[] = [
tagResolves,
manifestAtCommit,
artifactMatches,
readmeAtCommit,
ownerBinding,
signingDowngrade,
];
+630
View File
@@ -0,0 +1,630 @@
/**
* Every gate that is a pure function of the working tree.
*
* This is most of them. See ./types.ts for why that matters: the registry grades
* `trek-plugin.json` and `README.md` at a pinned commit, but those are the same two files
* the author has open right now, so there is no reason to make them push a tag to find out.
*
* Each check answers ONE question and says what to do about it. The `fix` text is the whole
* point — a check that reports "README invalid" and stops has told the author nothing they
* can act on.
*/
import { createHash } from 'node:crypto';
import path from 'node:path';
import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS } from '../../manifest.js';
import { LUCIDE_ICON_NAMES } from '../../lucide-icon-names.js';
import { listZipNames } from '../../zip.js';
import { checkSignatureShape } from '../verify-signature.js';
import type { OfflineCheck, CheckContext } from './types.js';
import { pass, fail, skip } from './types.js';
import {
REQUIRED_SECTIONS,
MIN_PROSE_CHARS,
missingSections,
placeholders,
proseLength,
images,
undocumentedPermissions,
} from './readme.js';
/** Mirrors the registry's native-binary scan (validate-entry.mjs) and the installer's. */
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const arr = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []);
/** The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the only list that counts. */
function grantedHosts(permissions: string[]): string[] {
return permissions.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length)).filter(Boolean);
}
// ── manifest ────────────────────────────────────────────────────────────────────
const manifestPresent: OfflineCheck = {
id: 'manifest.present',
blocks: 'artifact',
stage: 'manifest',
depth: 'offline',
severity: 'error',
title: 'trek-plugin.json exists and parses',
run: (c) =>
c.manifest
? pass()
: fail(
c.manifestError ?? 'no trek-plugin.json',
'Every plugin is rooted at a trek-plugin.json manifest. Scaffold one rather than writing it by hand.',
'trek-plugin create',
),
};
const manifestValid: OfflineCheck = {
id: 'manifest.valid',
blocks: 'artifact',
stage: 'manifest',
depth: 'offline',
severity: 'error',
title: 'Manifest passes the same rules the TREK installer applies',
run: (c) => {
if (!c.manifest) return skip();
const r = validateManifest(c.manifest);
if (r.ok) return pass();
return fail(
`${r.errors.length} problem${r.errors.length === 1 ? '' : 's'}`,
r.errors.map((e) => '• ' + e).join('\n'),
);
},
};
const manifestIdMatchesDir: OfflineCheck = {
id: 'manifest.id-matches-dir',
stage: 'manifest',
depth: 'offline',
severity: 'warn',
title: 'Directory name matches the plugin id',
run: (c) => {
const id = str(c.manifest?.id);
if (!id) return skip();
const dirName = path.basename(c.dir);
return dirName === id
? pass()
: fail(
`directory is "${dirName}", id is "${id}"`,
'Not fatal, but the registry files your entry under the id, and every doc assumes the two agree.',
);
},
};
const manifestTrekRange: OfflineCheck = {
id: 'manifest.trek-range',
stage: 'manifest',
depth: 'offline',
severity: 'warn',
title: 'TREK version range is bounded',
run: (c) => {
const trek = str(c.manifest?.trek);
// Unsatisfiable/missing is an ERROR, but manifest.valid already reports it — don't say it twice.
if (!isSatisfiableRange(trek)) return skip();
return isUnboundedRange(trek)
? fail(
`trek: "${trek}"`,
'This claims support for every TREK version, including ones that do not exist yet. It opts you out of the\n' +
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=3.4.0 <4.0.0".',
)
: pass(trek);
},
};
const manifestIcon: OfflineCheck = {
id: 'manifest.icon',
stage: 'manifest',
depth: 'offline',
severity: 'error',
title: 'Icon is a real lucide name',
run: (c) => {
if (!c.manifest) return skip();
const icon = str(c.manifest.icon);
// Absent is LEGAL — TREK falls back to Blocks and CI accepts it. But every plugin without
// an icon is the same grey square in the store, so say so. Warn-shaped, reported as a pass
// with a caveat rather than a failure, because refusing to publish over it would be absurd.
if (!icon) return pass('none — TREK will show the generic Blocks glyph');
return LUCIDE_ICON_NAMES.has(icon)
? pass(icon)
: fail(
`"${icon}" is not a lucide icon`,
'TREK resolves the icon against lucide at render time and silently falls back to Blocks, so a typo is\n' +
'invisible locally — but the registry rejects it. Pick a name from https://lucide.dev/icons (PascalCase).',
);
},
};
/**
* The registry entry's schema, applied to the manifest fields it is built from.
*
* These are cheap string-length rules, and every one of them is a gate that today fails in CI
* — AFTER the release is immutable — because nothing local looks at them. `entry.ts` copies
* `description` straight out of the manifest and defaults it to `""`, which trips the schema's
* minLength of 5 with an error that names the ENTRY, not the manifest that caused it.
*/
const manifestEntryFields: OfflineCheck = {
id: 'manifest.entry-fields',
stage: 'manifest',
depth: 'offline',
severity: 'error',
title: 'name/description/author fit the registry schema',
run: (c) => {
if (!c.manifest) return skip();
const problems: string[] = [];
const name = str(c.manifest.name);
const description = str(c.manifest.description);
const author = str(c.manifest.author);
if (name && (name.length < 2 || name.length > 60)) problems.push(`• name is ${name.length} chars (registry allows 2–60)`);
if (!description) problems.push('• description is missing — the registry requires one (5–200 chars); it is your store-card subtitle');
else if (description.length < 5) problems.push(`• description is ${description.length} chars (registry requires at least 5)`);
else if (description.length > 200) problems.push(`• description is ${description.length} chars (registry allows at most 200)`);
if (!author) problems.push('• author is missing — the entry would be published as "Unknown"');
else if (author.length > 80) problems.push(`• author is ${author.length} chars (registry allows at most 80)`);
// Reject a name that mixes Latin with Cyrillic/Greek look-alikes — the registry treats this
// as a spoofing attempt (a plugin that looks like someone else's in the store).
if (name && /[A-Za-z]/.test(name) && /[Ѐ-ӿͰ-Ͽ]/.test(name)) {
problems.push(`• name "${name}" mixes Latin with Cyrillic/Greek characters — the registry reads that as a homoglyph spoof`);
}
// `tags` and `homepage` are copied VERBATIM out of the manifest into the entry (entry.ts),
// and the registry's JSON schema validates both. Nothing local looked at either, so a tag with
// a capital or a space — the obvious thing to write — produced an entry CI rejected, after the
// release was cut and its bytes pinned.
const tags = Array.isArray(c.manifest.tags) ? c.manifest.tags.map(String) : [];
for (const t of tags) {
if (!/^[a-z0-9-]{2,24}$/.test(t)) {
problems.push(`• tag "${t}" is not a lowercase slug of 2–24 chars (the registry's schema rejects it)`);
}
}
if (tags.length > 8) problems.push(`• ${tags.length} tags — the registry allows at most 8`);
const homepage = str(c.manifest.homepage);
if (homepage) {
try {
// The schema demands format:uri, which in practice means an absolute URL.
const u = new URL(homepage);
if (!u.protocol) throw new Error('no protocol');
} catch {
problems.push(`• homepage "${homepage}" is not an absolute URL (the registry's schema requires one, e.g. https://…)`);
}
}
return problems.length
? fail(`${problems.length} problem${problems.length === 1 ? '' : 's'}`, problems.join('\n'))
: pass();
},
};
const manifestAddonsKnown: OfflineCheck = {
id: 'manifest.addons-known',
stage: 'manifest',
depth: 'offline',
severity: 'warn',
title: 'requiredAddons are addons TREK has',
run: (c) => {
const addons = arr(c.manifest?.requiredAddons);
if (!addons.length) return skip();
const unknown = addons.filter((a) => !KNOWN_ADDONS.includes(a));
return unknown.length
? fail(
unknown.join(', '),
`Not necessarily wrong — you may be targeting a newer TREK than this SDK knows about — but an addon\nTREK does not have can never be enabled, so the plugin would never activate. Known: ${KNOWN_ADDONS.join(', ')}.`,
)
: pass(addons.join(', '));
},
};
const manifestNoEmoji: OfflineCheck = {
id: 'manifest.no-emoji',
stage: 'manifest',
depth: 'offline',
severity: 'warn',
title: 'No emojis in name/description',
run: (c) => {
if (!c.manifest) return skip();
const EMOJI_RE = /[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}]/u;
const hit = EMOJI_RE.test(str(c.manifest.name)) || EMOJI_RE.test(str(c.manifest.description));
return hit
? fail(
'name/description contains emojis',
'TREK renders with lucide icons and STRIPS emojis from plugin-rendered text and notifications, so they\n' +
'simply vanish at render. Use the `icon` field with a lucide name instead.',
)
: pass();
},
};
// ── code ────────────────────────────────────────────────────────────────────────
const codeServerEntry: OfflineCheck = {
id: 'code.server-entry',
blocks: 'artifact',
stage: 'code',
depth: 'offline',
severity: 'error',
title: 'server/index.js exists',
run: (c) =>
c.exists('server/index.js')
? pass()
: fail(
'server/index.js is missing',
'Every plugin — even a pure-UI one — is loaded from server/index.js via definePlugin(). If you build from\nTypeScript, build before packing.',
),
};
/**
* The egress trap, and the reason this check exists at all.
*
* The manifest has TWO places that look like they control network access, and only one of them
* does. `spawnActivated` (server plugin-runtime.service.ts) builds the child's allow-list purely
* from `http:outbound:<host>` PERMISSIONS, and the iframe CSP's connect-src comes from the same
* place. The manifest's `egress[]` array is never read at runtime at all — it is a declaration
* for the consent screen.
*
* So a plugin with `egress: ["api.example.com"]` and permission `http:outbound` — no host suffix
* — installs, activates, consents, and is then SILENTLY BLOCKED from the one host it exists to
* call. Nothing in the manifest validator, the packer or the registry catches it. The author
* finds out from a production timeout.
*/
const codeEgressReachable: OfflineCheck = {
id: 'code.egress-reachable',
stage: 'code',
depth: 'offline',
severity: 'error',
title: 'Every egress host has a matching http:outbound:<host> permission',
run: (c) => {
if (!c.manifest) return skip();
const egress = arr(c.manifest.egress);
const permissions = arr(c.manifest.permissions);
if (!egress.length) return skip();
const granted = grantedHosts(permissions);
const unreachable = egress.filter((h) => !granted.includes(h));
if (!unreachable.length) return pass(`${egress.length} host${egress.length === 1 ? '' : 's'}`);
return fail(
unreachable.join(', '),
'TREK opens the network guard and the iframe CSP from the `http:outbound:<host>` PERMISSIONS only — it never\n' +
'reads egress[]. These hosts are declared but unreachable: the plugin will install, activate, and then have\n' +
'every request to them silently blocked.\n' +
'Add to permissions: ' + unreachable.map((h) => `"http:outbound:${h}"`).join(', '),
);
},
};
const codeEgressDeclared: OfflineCheck = {
id: 'code.egress-declared',
stage: 'code',
depth: 'offline',
severity: 'warn',
title: 'Every reachable host is declared in egress[]',
run: (c) => {
if (!c.manifest) return skip();
const egress = arr(c.manifest.egress);
const granted = grantedHosts(arr(c.manifest.permissions));
if (!granted.length) return skip();
const undeclared = granted.filter((h) => !egress.includes(h));
return undeclared.length
? fail(
undeclared.join(', '),
'These hosts are reachable (you granted http:outbound:<host>) but absent from egress[]. egress[] is what the\n' +
'admin sees on the consent screen, so the plugin is understating its own network reach. Add them to egress[].',
)
: pass();
},
};
const codeDesignKit: OfflineCheck = {
id: 'code.design-kit',
stage: 'code',
depth: 'offline',
severity: 'warn',
title: 'Client UI inlines the TREK design kit',
run: (c) => {
if (!c.clientHtml) return skip();
const rawSelect = /<select(?![^>]*\bdata-trek-native\b)(\s|>)/i.test(c.clientHtml);
const usesKit = c.clientHtml.includes('<!-- trek:ui -->') || c.clientHtml.includes('data-trek-ui');
return rawSelect && !usesKit
? fail(
'a raw <select> with no design kit',
'Native dropdowns are drawn by the OS and cannot match TREK. Add the <!-- trek:ui --> marker so selects are\n' +
'auto-styled, or mark a field data-trek-native to opt out on purpose.',
)
: pass();
},
};
// ── docs ────────────────────────────────────────────────────────────────────────
// The stage where nearly every first publish dies. All four of these are registry gates.
const docsReadmePresent: OfflineCheck = {
id: 'docs.readme-present',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README.md exists',
run: (c) => (c.readme === undefined ? fail('README.md is missing', 'The registry requires one at the repo root.') : pass()),
};
const docsReadmeSections: OfflineCheck = {
id: 'docs.readme-sections',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has all four required sections',
run: (c) => {
if (c.readme === undefined) return skip();
const missing = missingSections(c.readme);
return missing.length
? fail(
`missing ${missing.map((s) => `"## ${s}"`).join(', ')}`,
`The registry requires exactly these four headings: ${REQUIRED_SECTIONS.map((s) => `## ${s}`).join(', ')}.`,
)
: pass();
},
};
const docsReadmePlaceholders: OfflineCheck = {
id: 'docs.readme-placeholders',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has no template placeholders left',
run: (c) => {
if (c.readme === undefined) return skip();
const hits = placeholders(c.readme);
return hits.length
? fail(
hits.map((h) => `"${h}"`).join(', '),
'This is scaffold text you have not replaced yet. The registry rejects it — it is the signal that a README\nwas never actually written.',
)
: pass();
},
};
const docsReadmeProse: OfflineCheck = {
id: 'docs.readme-prose',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README has enough written content',
run: (c) => {
if (c.readme === undefined) return skip();
const n = proseLength(c.readme);
return n < MIN_PROSE_CHARS
? fail(
`${n}/${MIN_PROSE_CHARS} chars of prose`,
'Headings, tables, code blocks, links and images do not count — the registry strips them and measures what is\n' +
'left. It has to be sentences: what the plugin does, why someone would install it, how to set it up.',
)
: pass(`${n} chars`);
},
};
/**
* A screenshot that RESOLVES.
*
* The old check regexed the README for an image *link* and passed if it found one. The scaffold
* writes `![screenshot](./docs/screenshot.png)` and never creates docs/ — so the old check passed
* on a link to a file that does not exist, and the author learned the truth from CI, after the
* release was immutable. Resolve the path on disk instead.
*/
const docsScreenshot: OfflineCheck = {
id: 'docs.screenshot',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'README screenshot resolves to a real image',
run: (c) => {
if (c.readme === undefined) return skip();
const imgs = images(c.readme);
if (!imgs.length) {
return fail(
'no images in the README',
'The store card shows this image, and the registry rejects a plugin without one. Capture a 16:9 shot\n' +
'(1600×900 is ideal — the card crops the edges) and commit it as docs/screenshot.png.',
'trek-plugin shot',
);
}
const remote = imgs.filter((u) => /^https?:\/\//.test(u));
const local = imgs.filter((u) => !/^https?:\/\//.test(u));
const resolved = local.filter((u) => c.exists(u.replace(/^\.?\//, '')));
if (resolved.length) return pass(resolved[0]);
// Nothing local resolves. A remote URL might still satisfy CI, but we cannot know offline —
// don't claim a pass we haven't earned, and don't claim a failure we can't prove.
if (remote.length) return pass(`${remote.length} remote image(s) — preflight verifies they resolve`);
const missing = local.filter((u) => !c.exists(u.replace(/^\.?\//, '')));
return fail(
`${missing.join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist`,
'The README links this image but the file is not there, so the store card would be a broken image and the\n' +
'registry rejects it. Capture a 16:9 shot (1600×900 is ideal — the card crops the edges).',
'trek-plugin shot',
);
},
};
const docsPermissionsExplained: OfflineCheck = {
id: 'docs.permissions-explained',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'Every permission is explained in the README',
run: (c) => {
if (c.readme === undefined || !c.manifest) return skip();
const perms = arr(c.manifest.permissions);
if (!perms.length) return skip();
const undocumented = undocumentedPermissions(c.readme, perms);
return undocumented.length
? fail(
undocumented.join(', '),
'The registry requires every permission string you request to appear verbatim in the README, so a user\n' +
'installing your plugin can read why you need it. Add a row per permission to the "## Permissions" table.',
)
: pass(`${perms.length} permission${perms.length === 1 ? '' : 's'}`);
},
};
// ── release ─────────────────────────────────────────────────────────────────────
// These need an artifact or an entry, so they skip on the plain `status` path.
const releaseNoNativeBinaries: OfflineCheck = {
id: 'release.no-native-binaries',
blocks: 'artifact',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Artifact contains no native binaries',
run: (c) => {
if (!c.zipBytes) return skip();
let names: string[] = [];
try {
names = listZipNames(c.zipBytes);
} catch {
return fail('could not read the artifact as a zip');
}
const native = names.filter((n) => NATIVE_RE.test(n));
return native.length
? fail(
native.slice(0, 5).join(', '),
'Native modules (.node / binding.gyp / prebuilds) are forbidden in v1 — TREK runs plugins under Node\'s\n' +
'permission model in a child process and cannot sandbox native code.',
)
: pass(`${names.length} files`);
},
};
/**
* Manifest ↔ entry parity, offline.
*
* The registry re-derives the manifest from the pinned commit and refuses any entry that
* disagrees with it. When `entry.ts` builds the entry this is true by construction — but not on
* the `--merge` or `preflight --entry <file>` paths, where the entry can be hand-edited. And it
* was NOT true by construction until recently: buildEntry silently omitted requiredAddons and
* pluginDependencies, so any plugin that actually used one produced an entry CI would reject.
*/
const releaseEntryParity: OfflineCheck = {
id: 'release.entry-parity',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Registry entry matches the manifest',
run: (c) => {
if (!c.entry || !c.manifest) return skip();
const m = c.manifest;
const v = c.entry.versions[0];
if (!v) return fail('entry has no versions');
const problems: string[] = [];
if (str(m.id) !== c.entry.id) problems.push(`• manifest id "${str(m.id)}" != entry id "${c.entry.id}"`);
if (str(m.version) !== v.version) problems.push(`• manifest version "${str(m.version)}" != entry version "${v.version}"`);
if (str(m.type) !== c.entry.type) problems.push(`• manifest type "${str(m.type)}" != entry type "${c.entry.type}"`);
const mApi = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
if (mApi !== v.apiVersion) problems.push(`• manifest apiVersion ${mApi} != entry ${v.apiVersion}`);
if (str(m.trek) !== v.trek) problems.push(`• manifest trek "${str(m.trek)}" != entry trek "${v.trek}"`);
if ((m.operatorEgress === true) !== (v.operatorEgress === true)) {
problems.push(`• manifest operatorEgress ${m.operatorEgress === true} != entry ${v.operatorEgress === true}`);
}
const normAddons = (a: unknown) => [...arr(a)].sort();
if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
problems.push(`• manifest requiredAddons [${normAddons(m.requiredAddons)}] != entry [${normAddons(v.requiredAddons)}]`);
}
const normDeps = (d: unknown) =>
(Array.isArray(d) ? d.map((x) => `${(x as { id?: string })?.id}@${(x as { version?: string })?.version}`).sort() : []);
if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) {
problems.push(`• manifest pluginDependencies [${normDeps(m.pluginDependencies)}] != entry [${normDeps(v.pluginDependencies)}]`);
}
return problems.length
? fail(
`${problems.length} mismatch${problems.length === 1 ? '' : 'es'}`,
problems.join('\n') +
'\nThe registry re-reads your manifest at the pinned commit and refuses an entry that disagrees with it.',
)
: pass();
},
};
const releaseArtifactHash: OfflineCheck = {
id: 'release.artifact-hash',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Entry sha256/size match the packed artifact',
run: (c) => {
if (!c.zipBytes || !c.entry) return skip();
const v = c.entry.versions[0];
if (!v) return skip();
const sha = createHash('sha256').update(c.zipBytes).digest('hex');
const problems: string[] = [];
if (sha !== v.sha256) problems.push(`• artifact sha256 ${sha.slice(0, 12)}… != entry ${v.sha256.slice(0, 12)}…`);
if (c.zipBytes.length !== v.size) problems.push(`• artifact is ${c.zipBytes.length}B, entry declares ${v.size}B`);
return problems.length
? fail(
`${problems.length} mismatch${problems.length === 1 ? '' : 'es'}`,
problems.join('\n') + '\nThe entry was built from different bytes than the artifact on disk — re-pack, then rebuild the entry.',
)
: pass();
},
};
/**
* A key without a signature, or a signature without a key.
*
* Passes the registry's JSON schema — the two fields sit at different levels, so no schema can
* relate them — and is then REFUSED by TREK at install time. A half-signed entry is dead on
* arrival, and it is a pure function of the entry, so there is no reason to wait for the network
* to say so.
*/
const releaseSignatureShape: OfflineCheck = {
id: 'release.signature-shape',
stage: 'release',
depth: 'offline',
severity: 'error',
title: 'Signature and public key are both present or both absent',
run: (c) => {
if (!c.entry) return skip();
const problems = checkSignatureShape(c.entry);
return problems.length
? fail(
`${problems.length} problem${problems.length === 1 ? '' : 's'}`,
problems.map((p) => '• ' + p).join('\n') + '\nTREK refuses to install a half-signed entry, so this one could never be installed.',
)
: pass(c.entry.authorPublicKey ? 'signed' : 'unsigned');
},
};
/** Every offline check, in the order `status` prints them. */
export const OFFLINE_CHECKS: OfflineCheck[] = [
manifestPresent,
manifestValid,
manifestEntryFields,
manifestIcon,
manifestTrekRange,
manifestIdMatchesDir,
manifestAddonsKnown,
manifestNoEmoji,
codeServerEntry,
codeEgressReachable,
codeEgressDeclared,
codeDesignKit,
docsReadmePresent,
docsReadmeSections,
docsReadmePlaceholders,
docsReadmeProse,
docsScreenshot,
docsPermissionsExplained,
releaseSignatureShape,
releaseNoNativeBinaries,
releaseEntryParity,
releaseArtifactHash,
];
export type { CheckContext };
+81
View File
@@ -0,0 +1,81 @@
/**
* The README quality gate, as pure functions of the markdown.
*
* These are a LINE-FOR-LINE port of TREK-Plugins' scripts/check-readme.mjs. They live apart
* from the checks that call them because the same gate runs twice, against two different
* READMEs, and both must agree with CI:
*
* - offline, against README.md in the WORKING TREE (`status` / `validate`)
* - over the network, against README.md at the PINNED COMMIT (`preflight`)
*
* The two differ more often than you would think — an author fills the README in and forgets
* to commit it, and the tree is green while the tag CI grades is not. Keeping one implementation
* means the offline pass can never be more lenient than the network one; only more current.
*
* If you change a rule here, change scripts/check-readme.mjs to match, or you have built a
* false green. test/checks-parity.test.ts asserts the two agree.
*/
/** Every section CI demands, matched case-insensitively as a substring of a heading. */
export const REQUIRED_SECTIONS = ['What it does', 'Screenshots', 'Permissions', 'Setup'];
/** Template prose that means the author never edited the scaffold. */
export const PLACEHOLDER_PATTERNS = [
/\{\{[^}]*\}\}/,
/\bREPLACE_ME\b/i,
/\bDescribe (what|the)\b/i,
/\byour-name\/trek-plugin/i,
];
export const MIN_PROSE_CHARS = 400;
/** Which of REQUIRED_SECTIONS have no matching heading. */
export function missingSections(md: string): string[] {
// The `(?:<!--.*)?` tail mirrors check-readme.mjs — it tolerates a trailing HTML comment
// on a heading line, which authors use to hide anchors.
const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*(?:<!--.*)?$/gm)].map((m) => m[1].toLowerCase());
return REQUIRED_SECTIONS.filter((want) => !headings.some((got) => got.includes(want.toLowerCase())));
}
/** The literal placeholder strings still present, e.g. ['Describe what']. */
export function placeholders(md: string): string[] {
const hits: string[] = [];
for (const re of PLACEHOLDER_PATTERNS) {
const hit = md.match(re);
if (hit) hits.push(hit[0]);
}
return hits;
}
/**
* The length of the author's actual WRITING, with everything that isn't prose stripped:
* comments, code fences, images, links, headings, table rows, then markdown punctuation.
*
* This is why a scaffold can't game the 400-char floor with a big permissions table — the
* table is stripped. It has to be sentences.
*/
export function proseLength(md: string): number {
return md
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+.*$/gm, '')
.replace(/^\s*\|.*$/gm, '')
.replace(/[#>*_`|-]/g, '')
.replace(/\s+/g, ' ')
.trim().length;
}
/** Every image src the README references — markdown and <img>, minus data: URIs (CI ignores those). */
export function images(md: string): string[] {
const mdImgs = [...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]);
const htmlImgs = [...md.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
return [...new Set([...mdImgs, ...htmlImgs])].filter((u) => !u.startsWith('data:'));
}
/** Manifest permissions that are never named anywhere in the README. CI rejects these. */
export function undocumentedPermissions(md: string, permissions: string[]): string[] {
const lower = md.toLowerCase();
return permissions.filter((p) => !lower.includes(p.toLowerCase()));
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Rendering a CheckReport for a human.
*
* Split from the checks themselves so the same outcomes can be a checklist you READ (`status`)
* or an exit code a script reads (`validate`) without either owning the rules.
*
* The thing that actually makes this useful is `nextStep()`. A list of failures tells an author
* what is wrong; it does not tell them what to DO, and with four things red the honest question
* is "which one first?". So the report always ends with exactly one command.
*/
import { STAGES, type CheckOutcome, type CheckReport } from './types.js';
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
/** Colour only when stderr is a terminal — piped output must stay clean for scripts. */
function colour(on: boolean) {
const c = (code: string) => (s: string) => (on ? code + s + RESET : s);
return { green: c(GREEN), red: c(RED), yellow: c(YELLOW), dim: c(DIM), bold: c(BOLD) };
}
function glyph(o: CheckOutcome): string {
if (o.status === 'skip') return '·';
if (o.status === 'pass') return '✓';
return o.severity === 'error' ? '✗' : '!';
}
/**
* The single next command.
*
* Preference order is deliberate: the FIRST failing error that names a command wins, and checks
* are ordered manifest → code → docs → release, so an author is always pointed at the earliest
* thing that is broken rather than the last. A plugin with a broken manifest and no screenshot
* should be told to fix the manifest; telling it to take a screenshot would be busywork.
*/
export function nextStep(report: CheckReport): { command?: string; hint: string } {
const firstWithCommand = report.errors.find((e) => e.next);
if (firstWithCommand?.next) {
return { command: firstWithCommand.next, hint: firstWithCommand.title };
}
const first = report.errors[0];
if (first) {
return { hint: `fix "${first.title}" above, then re-run \`trek-plugin status\`` };
}
return { command: 'trek-plugin publish --repo <owner/name> --tag v<x.y.z>', hint: 'everything checks out' };
}
export interface RenderOptions {
/** Show passing and skipped checks too. `status` does; `validate` only reports problems. */
verbose?: boolean;
colour?: boolean;
}
/** The full checklist, grouped by stage. This is what `status` prints. */
export function renderChecklist(report: CheckReport, opts: RenderOptions = {}): string {
const c = colour(opts.colour ?? false);
const lines: string[] = [];
for (const stage of STAGES) {
const outcomes = report.outcomes.filter((o) => o.stage === stage.id);
if (!outcomes.length) continue;
// A stage where everything skipped has nothing to say (e.g. `release` before you have packed).
const shown = opts.verbose ? outcomes : outcomes.filter((o) => o.status === 'fail');
if (!shown.length) continue;
lines.push('');
lines.push(' ' + c.bold(stage.title));
for (const o of shown) {
const g = glyph(o);
const paint = o.status === 'pass' ? c.green : o.status === 'skip' ? c.dim : o.severity === 'error' ? c.red : c.yellow;
const detail = o.detail ? c.dim(` — ${o.detail}`) : '';
const title = o.status === 'skip' ? c.dim(o.title) : o.title;
lines.push(` ${paint(g)} ${title}${detail}`);
// The `fix` is the part an author can act on, so it is never hidden behind a flag.
if (o.status === 'fail' && o.fix) {
for (const l of o.fix.split('\n')) lines.push(' ' + c.dim(l));
}
}
}
return lines.join('\n');
}
/** The plain one-problem-per-line form, for pipes and CI. `validate` prints this. */
export function renderPlain(report: CheckReport): string {
const lines: string[] = [];
for (const w of report.warnings) {
lines.push(`warning: ${w.title}${w.detail ? ` — ${w.detail}` : ''}`);
if (w.fix) for (const l of w.fix.split('\n')) lines.push(' ' + l);
}
for (const e of report.errors) {
lines.push(`error: ${e.title}${e.detail ? ` — ${e.detail}` : ''}`);
if (e.fix) for (const l of e.fix.split('\n')) lines.push(' ' + l);
}
return lines.join('\n');
}
/** "3 things block publishing." / "ready to publish." */
export function summaryLine(report: CheckReport): string {
const e = report.errors.length;
const w = report.warnings.length;
if (!e && !w) return 'ready to publish.';
const parts: string[] = [];
if (e) parts.push(`${e} thing${e === 1 ? '' : 's'} block${e === 1 ? 's' : ''} publishing`);
if (w) parts.push(`${w} warning${w === 1 ? '' : 's'}`);
return parts.join(', ') + '.';
}
+216
View File
@@ -0,0 +1,216 @@
/**
* The check registry: every gate the TREK-Plugins registry enforces, declared ONCE.
*
* The problem this solves: the registry's gates are, almost all of them, functions of two
* files that are sitting in the author's working directory (`trek-plugin.json`, `README.md`).
* But CI reads them over the network at a pinned commit, so we used to only check them over
* the network too — from `preflight`, which runs against a PUSHED TAG. By then the author has
* cut an immutable GitHub release, and the registry pins its sha256. Learning there that the
* README is 61 chars short is learning it one step too late.
*
* So each check declares what it NEEDS rather than where it runs:
*
* depth: 'offline' — a pure function of the working tree. Runs in `status`, `validate`,
* `preflight` and as `publish`'s first step. This is most of them.
* depth: 'network' — genuinely needs GitHub or the registry repo (a tag must resolve to a
* commit; an artifact must download and hash). Only four are really
* like this. They run in `preflight` and `publish`.
*
* One list, three depths. The contract inherited from preflight.ts still holds and now covers
* `validate` too: A GATE THE REGISTRY HAS AND THIS FILE DOESN'T IS A FALSE GREEN, and an
* author trusts a green. When TREK-Plugins' scripts/validate-entry.mjs or check-readme.mjs
* grows a rule, it grows one here — test/checks-parity.test.ts is what stops the two drifting.
*/
/**
* The journey, in the order an author walks it. `status` prints its checklist grouped by
* these, so they double as the answer to "where am I?".
*/
export type CheckStage = 'manifest' | 'code' | 'docs' | 'release';
export const STAGES: { id: CheckStage; title: string }[] = [
{ id: 'manifest', title: 'Manifest' },
{ id: 'code', title: 'Code' },
{ id: 'docs', title: 'Docs' },
{ id: 'release', title: 'Release' },
];
/** What a check needs in order to run. See the file header. */
export type CheckDepth = 'offline' | 'network';
/**
* 'error' fails `validate` (and blocks `publish`). 'warn' never fails a command — it is for
* things that are legal but almost always a mistake, where refusing would be presumptuous.
*
* The bar for 'error' is: THE REGISTRY WOULD REJECT THIS, or TREK WOULD REFUSE TO RUN IT.
* Anything softer is a warn. Getting this wrong in the lenient direction is a false green,
* which is the bug class this whole module exists to kill.
*/
export type CheckSeverity = 'error' | 'warn';
/**
* What a failure actually STOPS YOU DOING. Severity says how bad it is; this says when it bites.
*
* The distinction is not academic. `pack` validates before it zips, so without this axis an
* unwritten README would refuse to build an artifact — and packing is how you install a plugin
* into a local TREK to try it. Blocking the dev loop on a docs gate that only matters at publish
* time would be exactly the over-eager gatekeeping this rework exists to remove.
*
* 'artifact' — the plugin is broken. It cannot be built, loaded or installed. `pack` refuses.
* 'publish' — the plugin works, but the registry would reject it. `pack` allows it; `validate`
* and `publish` do not.
*
* A 'warn' never blocks anything, whatever it is tagged with.
*/
export type CheckBlocks = 'artifact' | 'publish';
export type CheckStatus = 'pass' | 'fail' | 'skip';
export interface CheckResult {
status: CheckStatus;
/** The specific, quantified finding: "61/400 chars of prose". Shown next to the title. */
detail?: string;
/** A human sentence: what to actually do about it. Shown indented under a failure. */
fix?: string;
/** The literal command that fixes this, e.g. `trek-plugin shot`. Drives "next →". */
next?: string;
}
export const pass = (detail?: string): CheckResult => ({ status: 'pass', detail });
export const skip = (detail?: string): CheckResult => ({ status: 'skip', detail });
export const fail = (detail: string, fix?: string, next?: string): CheckResult => ({
status: 'fail',
detail,
fix,
next,
});
/**
* Everything a check may read. Assembled once by `loadContext()` so that N checks don't do N
* reads of the same README, and so tests can hand-build a context without touching a disk.
*/
export interface CheckContext {
/** Absolute path to the plugin directory. */
dir: string;
/** Parsed `trek-plugin.json`, or undefined when it is missing or unparseable. */
manifest?: Record<string, unknown>;
/** Why the manifest is absent, when it is — "no trek-plugin.json" / a JSON parse error. */
manifestError?: string;
/** `README.md`, or undefined when missing. */
readme?: string;
/** `client/index.html`, when the plugin has one. */
clientHtml?: string;
/** Does a path relative to `dir` exist? The seam that lets tests fake a tree. */
exists: (rel: string) => boolean;
/**
* The packed artifact's bytes. Present only on the `pack`/`publish` path — checks that
* need it must `skip` when it is absent rather than fail, or `status` would demand a zip
* from an author who has not packed one and has no reason to.
*/
zipBytes?: Buffer;
/** The built registry entry — network checks only. */
entry?: RegistryEntry;
/** The registry repo to check owner binding + signing history against. Network checks only. */
registry?: string;
/** Check every version in the entry, not just the newest. Mirrors `preflight --all`. */
allVersions?: boolean;
}
/** The shape `entry.ts` builds and TREK-Plugins stores at registry/plugins/<id>.json. */
export interface RegistryEntry {
id: string;
name: string;
author: string;
description: string;
repo: string;
type: string;
icon?: string;
homepage?: string;
tags?: string[];
authorPublicKey?: string;
versions: RegistryEntryVersion[];
}
export interface RegistryEntryVersion {
version: string;
gitTag: string;
commitSha: string;
downloadUrl: string;
sha256: string;
trek: string;
size: number;
apiVersion: number;
nativeModules: false;
operatorEgress?: boolean;
signature?: string;
publishedAt: string;
requiredAddons?: string[];
pluginDependencies?: { id: string; version: string }[];
}
interface CheckBase {
/** Stable dotted id, e.g. 'readme.prose'. Tests and `--only` address checks by this. */
id: string;
stage: CheckStage;
severity: CheckSeverity;
/** When a failure bites. Defaults to 'publish' — most gates are the registry's, not the loader's. */
blocks?: CheckBlocks;
/** Imperative and specific — it reads as a checklist line: "README has all four sections". */
title: string;
}
/**
* An offline check is SYNCHRONOUS, and that is enforced by the type rather than by convention.
*
* It buys two things. It keeps `validate` synchronous, so `packPluginDir` — which validates
* before it zips — does not have to become async and drag `publish`/`release`/`entry` with it.
* And it makes "this check does no IO" a thing the compiler checks: an offline check that
* reaches for the network cannot await, so it cannot quietly become a network check.
*/
export interface OfflineCheck extends CheckBase {
depth: 'offline';
run(ctx: CheckContext): CheckResult;
}
export interface NetworkCheck extends CheckBase {
depth: 'network';
run(ctx: CheckContext): Promise<CheckResult>;
}
export type Check = OfflineCheck | NetworkCheck;
/** One check plus how it turned out. */
export interface CheckOutcome extends CheckResult {
id: string;
stage: CheckStage;
severity: CheckSeverity;
blocks: CheckBlocks;
title: string;
}
export interface CheckReport {
outcomes: CheckOutcome[];
/** Failures of severity 'error' — these block `validate` and `publish`. */
errors: CheckOutcome[];
/** Failures of severity 'warn' — reported, never fatal. */
warnings: CheckOutcome[];
/** No errors. Warnings do not affect this. */
ok: boolean;
}
export function summarize(outcomes: CheckOutcome[]): CheckReport {
const failed = outcomes.filter((o) => o.status === 'fail');
const errors = failed.filter((o) => o.severity === 'error');
const warnings = failed.filter((o) => o.severity === 'warn');
return { outcomes, errors, warnings, ok: errors.length === 0 };
}
/**
* The subset of a report that blocks a given action. `pack` asks for 'artifact' — it refuses to
* zip a plugin that could never load, but does not care that the README is still a stub.
*/
export function blocking(report: CheckReport, action: CheckBlocks): CheckReport {
if (action === 'publish') return report;
const errors = report.errors.filter((e) => e.blocks === 'artifact');
return { ...report, errors, ok: errors.length === 0 };
}
+136 -6
View File
@@ -15,6 +15,7 @@ import {
PERMISSION_CATALOG,
} from './ui.js';
import { KNOWN_ADDONS } from '../manifest.js';
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
import { notifySdkUpdate } from './update-notice.js';
/** This package's own version, for the scaffold's devDependency range. */
@@ -48,10 +49,38 @@ export interface ScaffoldOptions {
* channel is a plain `integration` that implements the `notificationChannel` hook.
*/
template?: 'blank' | 'notification-channel';
/** A lucide icon name (PascalCase). Falls back to a sensible default for the type. */
icon?: string;
/** Where a `widget` renders. Omitting it silently means 'sidebar'. */
slot?: WidgetSlot;
}
export const TEMPLATES = ['blank', 'notification-channel'] as const;
/** Mirrors the server's widget slots (and the manifest validator's). */
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
export type WidgetSlot = (typeof WIDGET_SLOTS)[number];
export const WIDGET_SLOT_HINTS: Record<WidgetSlot, string> = {
sidebar: 'The trip sidebar, alongside weather and countdown (the default)',
hero: 'The banner at the top of the trip',
'place-detail': "Inside a place's detail panel",
'day-detail': "Inside a day's detail panel",
'reservation-detail': "Inside a reservation's detail panel",
};
/**
* A defensible starting icon per plugin type. Not a guess at what the plugin DOES — it cannot know
* that — just a better default than every new plugin sharing the generic `Blocks` glyph.
*/
function defaultIconFor(type: string, isChannel: boolean): string {
if (isChannel) return 'BellRing';
if (type === 'widget') return 'LayoutGrid';
if (type === 'page') return 'BookOpen';
if (type === 'trip-page') return 'Map';
return 'Plug';
}
export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void {
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`);
@@ -92,7 +121,11 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
author: opts.author || 'Your Name',
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
type,
trek: '>=3.3.0 <4.0.0',
// 3.4.0, not 3.3.0. The `ctx` namespaces this scaffold is written against — ctx.meta,
// ctx.places, ctx.days, ctx.itinerary, ctx.costs, ctx.packing, ctx.files — only exist from
// 3.4.0. Claiming 3.3.0 lets the plugin install on a host where they are `undefined`, which
// fails at the first call with a TypeError instead of a clear "incompatible" at install.
trek: '>=3.4.0 <4.0.0',
nativeModules: false,
permissions: perms,
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
@@ -101,6 +134,10 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
requiredAddons: opts.requiredAddons ?? [],
pluginDependencies: opts.pluginDependencies ?? [],
};
// The store tile, the nav entry, the widget header and the admin row all render this. Omitting
// it is legal — TREK falls back to `Blocks` — but then every plugin that skipped it is the same
// grey square in the store, so the scaffold picks a real one rather than leaving it out.
manifest.icon = opts.icon && LUCIDE_ICON_NAMES.has(opts.icon) ? opts.icon : defaultIconFor(type, isChannel);
if (egress.length) manifest.egress = egress;
// A notification channel usually targets a SELF-HOSTED service, whose hostname the
// author cannot know at publish time. Declaring operatorEgress lets the admin add the
@@ -113,7 +150,12 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
// host ignores a manifest `routes`. A `page` plugin gets its nav entry
// automatically, so there's no `capabilities.nav` either; only `widget` carries a
// capability worth scaffolding.
if (type === 'widget') manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium' } };
// `slot` decides WHERE the widget renders, and omitting it silently means 'sidebar' — so a
// widget meant for a place's detail panel would quietly appear in the trip sidebar instead,
// with nothing anywhere to say why. Always write it.
if (type === 'widget') {
manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium', slot: opts.slot ?? 'sidebar' } };
}
if (isChannel) {
manifest.capabilities = { notificationChannel: { title: displayName } };
// The recipient's own credential. `scope: 'user'` is what makes it per-user, and
@@ -160,8 +202,44 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
}
// The README has always linked ./docs/screenshot.png, and the scaffold has never created the
// directory — so the very first thing an author was told to do had nowhere to go, and the only
// thing that noticed was the registry, after the release was immutable. Make the destination
// real, and say what belongs in it.
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
fs.writeFileSync(path.join(root, 'docs', 'README.md'), DOCS_README);
// Normalise line endings. The registry pins the artifact's sha256, and a CRLF checkout produces
// different bytes for the same source — so a Windows author re-packing the same commit gets a
// hash that no longer matches the entry they published.
fs.writeFileSync(path.join(root, '.gitattributes'), GITATTRIBUTES);
}
const DOCS_README = `Put \`screenshot.png\` here.
It is what the plugin's store card shows, and the registry REJECTS a plugin whose README has no
screenshot that resolves to a real image — so this is not optional.
- 16:9, 1600×900 is ideal. The card crops the edges, so leave some margin.
- Show the plugin doing its job, in context, inside TREK.
The quickest way: run \`trek-plugin dev\`, then \`trek-plugin shot\` in another terminal — it
renders your plugin in a themed TREK frame and writes docs/screenshot.png for you.
This directory is NOT shipped in plugin.zip; it lives in your repo, and the README links it at the
commit the registry pins.
`;
const GITATTRIBUTES = `# The registry pins the sha256 of your packed artifact. A CRLF checkout changes those bytes for
# the same source, so re-packing the same commit on Windows would produce a hash that no longer
# matches the entry you published. Pin the line endings and the artifact stays reproducible.
* text=auto eol=lf
*.png binary
*.jpg binary
*.zip binary
`;
const CHANNEL_JS = `// A TREK notification channel — runs in an isolated child process.
//
// TREK renders every notification into the recipient's language and hands it to you
@@ -436,11 +514,38 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
required: false,
});
// A widget with no slot silently renders in the sidebar. Ask, rather than let an author
// discover months later that `hero` was always available.
let slot: WidgetSlot | undefined;
if (type === 'widget') {
slot = await promptSelect<WidgetSlot>({
message: 'Where should the widget appear?',
initialValue: 'sidebar',
options: WIDGET_SLOTS.map((s) => ({ value: s, label: s, hint: WIDGET_SLOT_HINTS[s] })),
});
}
// The store tile, the nav entry and the admin row all render this. The registry REJECTS a name
// lucide does not have, so validate it here rather than at publish time.
const fallbackIcon = defaultIconFor(type, template === 'notification-channel');
const icon = await promptText({
message: 'Icon — a lucide name (see https://lucide.dev/icons)',
placeholder: fallbackIcon,
defaultValue: fallbackIcon,
validate: (v) => {
const name = (v ?? '').trim();
if (!name) return undefined; // the default is used
return LUCIDE_ICON_NAMES.has(name) ? undefined : `lucide has no icon called "${name}" — TREK would fall back to Blocks`;
},
}).then((v) => v.trim() || fallbackIcon);
note(
[
`id ${id}`,
`type ${type}`,
template !== 'blank' ? `template ${template}` : undefined,
slot ? `slot ${slot}` : undefined,
`icon ${icon}`,
`location ${dest}`,
`author ${author}`,
`permissions ${permissions.join(', ') || '(none)'}`,
@@ -457,7 +562,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
process.exit(0);
}
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template });
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template, icon, slot });
logSuccess(`Created ${dest}`);
if (!insideGitRepo(parentDir)) {
@@ -485,11 +590,35 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
}
}
const cd = path.relative(process.cwd(), dest) || dest;
outro(`Next steps:\n cd ${cd}\n npx trek-plugin-sdk dev`);
outro(nextStepsAfterCreate(dest));
return id;
}
/**
* What to do next, said ONCE.
*
* There used to be three of these — the wizard's outro, the `create` dispatch branch and the
* standalone `create-trek-plugin` bin — and they named three different next commands (`dev`,
* `dev`, `validate`). Whichever an author hit was a coin toss, and none of them mentioned that
* the plugin they had just been handed could not be published as-is.
*
* So it points at `dev` (the thing you actually want to do next) and at `status` (the thing that
* will tell you the truth about what is still missing), and it does not pretend the scaffold is
* finished.
*/
export function nextStepsAfterCreate(dest: string): string {
const cd = path.relative(process.cwd(), dest) || dest;
return [
'Next steps:',
` cd ${cd}`,
' trek-plugin dev run it locally, hot-reloaded',
' trek-plugin status what is left before you can publish',
'',
'The scaffold is not publishable yet — it needs a written README and a screenshot.',
'`status` lists exactly what, whenever you ask it.',
].join('\n');
}
// CLI entry
if (process.argv[1] && process.argv[1].endsWith('create.js')) {
notifySdkUpdate();
@@ -505,7 +634,8 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
}
try {
scaffold(name, type, process.cwd(), { template });
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`);
// The SAME message the wizard and `trek-plugin create` print. These three used to disagree.
console.log(nextStepsAfterCreate(path.join(process.cwd(), name)));
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
+14 -3
View File
@@ -392,7 +392,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
// hard-coded to 42 while the scaffold seeds trip 1, so the widget's first
// trek:invoke hit assertMember(42, …) and 500'd with RESOURCE_FORBIDDEN.
const previewTripId = Number(Object.keys(fx.trips ?? {})[0] ?? 1) || 1;
return send(200, preview(id, String(manifest.type), previewTripId), 'text/html; charset=utf-8');
// ?chrome=0 strips the dev toolbar and centres the plugin — what `trek-plugin shot`
// renders into docs/screenshot.png. ?theme=dark picks the dark palette.
const chrome = url.searchParams.get('chrome') !== '0';
const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme }), 'text/html; charset=utf-8');
}
// Static plugin UI at /ui (page/widget client bundle). The iframe loads
@@ -520,12 +524,19 @@ ${LIVE_RELOAD}`;
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
*/
function preview(id: string, type: string, tripId: number): string {
function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark' } = {}): string {
const maxW = type === 'widget' ? '440px' : '1000px';
// `trek-plugin shot` renders this page into docs/screenshot.png — the image the plugin store
// shows. The theme picker and the "runs sandboxed at an opaque origin" note are DEV furniture:
// useful while you build, absurd on a store card. So shot mode strips them and centres the
// plugin in the frame, and the shot is of the plugin rather than of the tool that drew it.
const chrome = opts.chrome !== false;
const dark = opts.theme === 'dark';
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
<style>
*{box-sizing:border-box}
${chrome ? '' : 'header,.hint{display:none!important}.stage{min-height:100vh;align-items:center;padding:0 20px}'}
body{margin:0;font:14px/1.5 system-ui,-apple-system,sans-serif;background:#f4f5f7;color:#111827}
body.dark{background:#0e0e11;color:#e5e7eb}
header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:inherit;z-index:2}
@@ -545,7 +556,7 @@ iframe{width:100%;border:0;background:transparent;min-height:120px;display:block
<header>
<strong>${id}</strong><span style="opacity:.5">· ${type} preview</span>
<span class="sp"></span>
<label>Theme <select id="theme"><option value="light">light</option><option value="dark">dark</option></select></label>
<label>Theme <select id="theme"><option value="light"${dark ? '' : ' selected'}>light</option><option value="dark"${dark ? ' selected' : ''}>dark</option></select></label>
<label>Accent <select id="accent"><option value="default">default</option><option value="indigo">indigo</option><option value="teal">teal</option><option value="rose">rose</option></select></label>
<label><input type="checkbox" id="rm"> reduce motion</label>
<label><input type="checkbox" id="nt"> no transparency</label>
+27
View File
@@ -33,7 +33,23 @@ interface Version {
trek: string;
size: number; apiVersion: number;
nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string;
/**
* The manifest's dependency fields, mirrored.
*
* These are NOT optional decoration. TREK resolves requiredAddons and pluginDependencies from
* the registry INDEX, before it ever downloads the artifact — so an entry that omits them
* resolves against a dependency set the code does not actually have. Both the registry's CI
* (validate-entry.mjs) and `preflight` parity-check them against the manifest at the pinned
* commit, which means an entry built without them fails for any plugin that declares one.
*
* They were missing here until now, so `requiredAddons: ["budget"]` in a manifest produced an
* entry CI rejected with "manifest requiredAddons != entry requiredAddons" — after the release
* had been cut and its bytes pinned. Emitted only when non-empty, so ordinary entries are
* byte-identical to before.
*/
requiredAddons?: string[]; pluginDependencies?: PluginDependency[];
}
interface PluginDependency { id: string; version: string }
interface Entry {
id: string; name: string; author: string; description: string; repo: string;
homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[];
@@ -97,6 +113,17 @@ export function buildEntry(opts: {
// hosts after install). Only emitted when true, so ordinary entries stay unchanged.
if (manifest.operatorEgress === true) version.operatorEgress = true;
// Mirror the dependency fields. See the Version interface for why omitting these was a bug:
// TREK resolves them from the index before downloading, and CI refuses an entry that
// disagrees with the manifest. Only emitted when non-empty — an empty array and an absent
// one normalise identically on both sides of the parity check.
const requiredAddons = Array.isArray(manifest.requiredAddons) ? manifest.requiredAddons.map(String) : [];
if (requiredAddons.length) version.requiredAddons = requiredAddons;
const pluginDependencies = Array.isArray(manifest.pluginDependencies)
? (manifest.pluginDependencies as PluginDependency[]).map((d) => ({ id: String(d?.id), version: String(d?.version) }))
: [];
if (pluginDependencies.length) version.pluginDependencies = pluginDependencies;
// Optional author signature over the exact artifact bytes.
let authorPublicKey: string | undefined;
if (opts.signKeyPath) {
+241
View File
@@ -0,0 +1,241 @@
/**
* Help, tiered.
*
* The old help was eleven commands in one flat list, one line each, with no order and no
* per-command detail — `trek-plugin dev --help` fell through to the same flat list. So the only
* description of any command was that one line, and nothing anywhere said which to run first.
*
* Two changes. The top-level lists THE PATH (create → dev → status → publish) and pushes the
* other seven to an "Also" line: they are real commands, and an author who knows what `entry`
* is can still reach for it, but nobody has to learn eleven things to publish a plugin. And
* every command now has its own page, with what it does, when you would reach for it, and its
* flags.
*/
export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`;
/** The four commands that ARE the job. Everything else is a step one of these already does. */
export const PATH_COMMANDS = ['create', 'dev', 'status', 'publish'] as const;
/** Real, supported, and not what a newcomer needs to see first. */
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release'] as const;
export interface CommandHelp {
/** One line, for the top-level list. */
summary: string;
/** Usage line. */
usage: string;
/** What it does and — more usefully — when you would reach for it. */
body: string;
}
export const COMMAND_HELP: Record<string, CommandHelp> = {
create: {
summary: 'scaffold a new plugin',
usage: 'trek-plugin create [name] [--type integration|page|widget|trip-page] [--template blank|notification-channel]',
body: `Scaffolds a plugin: manifest, server entry, README, and a client UI for anything that is not
an integration. With no name it runs a wizard that asks for the id, type, icon, permissions,
egress hosts and required addons.
The plugin it gives you RUNS immediately — \`dev\` and \`pack\` work — but it is not publishable
yet: the README is a template and there is no screenshot. That is deliberate, and \`status\` will
tell you exactly what is left.
--type integration server-only: routes, hooks, background work
page a full navigation page (sandboxed iframe UI)
widget a dashboard widget (sandboxed iframe UI)
trip-page a tab inside every trip (sandboxed iframe UI)
--template blank | notification-channel
--author author name for the manifest
--description one-line description (5-200 chars; the registry requires it)
--permissions "db:own db:read:trips"
--egress api.example.com,api.other.com
--required-addons budget,packing
--interactive force the wizard even when a name is given`,
},
dev: {
summary: 'run it locally, hot-reloaded',
usage: 'trek-plugin dev [dir] [--port 4317]',
body: `Runs the plugin against a mock TREK host, with hot reload. It enforces the SAME permissions
your manifest declares, so an ungranted call throws PERMISSION_DENIED here exactly as it would in
production, and \`db:own\` is backed by a real SQLite file under .trek-dev/.
/ a dashboard: your routes, your grants, what is wired up
/preview your UI in the themed, sandboxed frame TREK actually renders it in
/ui your UI raw, unframed
/api/* your routes
/__dev/fire/<kind> fire a job, a scheduled run, an event or a hook by hand
Binds 127.0.0.1 only. Editing server/ or client/ reloads.`,
},
status: {
summary: "where am I? what's left?",
usage: 'trek-plugin status [dir]',
body: `The command to run when you are not sure what to do next.
Runs every check the TREK-Plugins registry enforces that can be answered without a network — which
is nearly all of them — and prints the whole journey as a checklist: what passes, what does not,
what is still to come, and the ONE command to run next.
It never fails. It is for orientation, not for gating; \`validate\` is the gate. Run it as often as
you like.`,
},
publish: {
summary: 'release + open the registry PR',
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign]',
body: `The whole release, in order:
1. check every registry gate that can be checked locally
2. pack build plugin.zip
3. release git tag, push, and cut the GitHub release with the artifact
4. preflight the gates that need the tag and the release to exist
5. submit open the PR against the TREK-Plugins registry
Step 1 comes first for a reason: a GitHub release is effectively immutable, because the registry
pins its sha256. If a check fails, NOTHING is packed, tagged, pushed or released — so you can fix
it and re-run against the same version.
In a terminal it OFFERS TO SIGN, and creates a key for you if you have none. A signature proves the
artifact came from you, not just that its bytes match what the registry saw. You can add signing
later — publishing unsigned now and signing at v1.4.0 breaks nobody — but you can never take it
back: once a plugin has shipped signed, TREK refuses an unsigned update to it. So back the key up.
Scripts are never prompted; pass --sign.
Needs git and an authenticated \`gh\`.
--sign [key] sign the artifact (default key: ~/.trek-plugin/signing.key)
--registry o/n a registry other than mauriceboe/TREK-Plugins
--notes release notes
--draft open the registry PR as a draft
--force overwrite an existing release's artifact. Breaks the sha256 pin for
everyone who already installed that version — only for a release that
was never merged into the registry.
--no-checks skip step 1. An escape hatch for a re-run; never right on a first publish.
--no-preflight skip step 4.`,
},
validate: {
summary: 'check it against the registry gates',
usage: 'trek-plugin validate [dir]',
body: `The gate. Runs the same offline checks as \`status\` and exits non-zero if any of them would
be rejected by the registry. This is the form for scripts and CI; \`status\` is the form for humans.
A plugin that passes \`validate\` will pass every registry gate that does not require the tag and
the release to exist. Those four run in \`preflight\`.`,
},
pack: {
summary: 'build plugin.zip',
usage: 'trek-plugin pack [dir] [--out plugin.zip] [--json]',
body: `Builds the exact plugin.zip the TREK installer expects, and prints its sha256 and byte size.
It refuses to pack a plugin that could not LOAD — a broken manifest, a missing server entry, a
native binary. It does not enforce the registry's publish gates (an unwritten README, a missing
screenshot), because packing is how you install a plugin into a local TREK to try it, and the docs
only matter when you ship.
--json the PackResult on stdout, for scripts`,
},
shot: {
summary: 'capture docs/screenshot.png',
usage: 'trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]',
body: `Boots the dev server, renders your plugin in the themed frame TREK uses, and writes a 1600×900
docs/screenshot.png.
The registry requires a screenshot that resolves to a real image, and the store card shows it. This
is the fastest way to have one.
Needs Playwright, which is NOT a dependency of this SDK (it ships a browser):
npm i -D playwright && npx playwright install chromium
An \`integration\` plugin has no UI to render, so \`shot\` cannot help — screenshot the TREK surface
your plugin changes instead.
--dark render in TREK's dark theme
--no-serve shoot a dev server you are already running`,
},
keygen: {
summary: 'create an Ed25519 signing key',
usage: 'trek-plugin keygen [--key file]',
body: `Generates a signing key at ~/.trek-plugin/signing.key. One key for all your plugins.
BACK IT UP. Signing is a one-way door: once a plugin has shipped signed, TREK refuses an unsigned
update — and one signed with a different key — on every instance that already has it. Losing the
key means you cannot ship an update to your own plugin without a maintainer override.`,
},
sign: {
summary: 'print a signature for an artifact',
usage: 'trek-plugin sign [zip] [--key file]',
body: 'Prints the signature and public key for an artifact. Usually you want `publish --sign` instead.',
},
entry: {
summary: 'print the registry entry JSON',
usage: 'trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]',
body: `Builds the TREK-Plugins registry entry from your manifest, your packed artifact and your git tag —
so the sha256, size, commitSha and downloadUrl all come out of one command instead of being
computed by hand.
\`publish\` does this for you. Reach for it directly only when you are assembling a PR by hand.
--merge entry.json prepend this version onto an existing entry (the update case)
--sign [key] sign the artifact and pin the author key
--out file write it instead of printing it`,
},
preflight: {
summary: 'run the registry CI checks locally',
usage: 'trek-plugin preflight [dir] --repo <owner/name> --tag <vX.Y.Z> [--entry file.json] [--all]',
body: `Runs the registry's checks — including the ones that need the network: that your tag resolves to
the commit the entry pins, that the released artifact downloads and hashes to the pinned sha256,
that your plugin id is not bound to another GitHub owner, and that an update does not drop or
rotate a signing key you already published under.
\`publish\` runs this for you as step 4. Reach for it directly to check an entry you assembled by
hand, or to re-check a release you have already cut.
--all check every version in the entry, not just the newest`,
},
submit: {
summary: 'open the registry PR',
usage: 'trek-plugin submit [dir] --repo <owner/name> --tag <vX.Y.Z> [--registry o/n] [--draft]',
body: `Forks the registry, writes registry/plugins/<id>.json, and opens the PR. Needs an authenticated
\`gh\`. \`publish\` does this for you as step 5.`,
},
release: {
summary: 'pack + cut the GitHub release',
usage: 'trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign] [--merge entry.json]',
body: `Packs, cuts the GitHub release, and prints the registry entry — but does NOT open the registry PR.
For when you want to release now and submit later. \`publish\` is the whole thing.`,
},
};
export function topLevelHelp(version: string): string {
const pad = (s: string) => s.padEnd(9);
const path = PATH_COMMANDS.map((c) => ` ${pad(c)} ${COMMAND_HELP[c].summary}`).join('\n');
return `${VERSION_LINE(version)} — build a plugin for TREK
The path
${path}
Also: ${OTHER_COMMANDS.join(' ')}
\`trek-plugin help <command>\` for any of them
New here? trek-plugin create
Lost? trek-plugin status`;
}
export function commandHelp(command: string): string | undefined {
const h = COMMAND_HELP[command];
if (!h) return undefined;
return `${h.usage}\n\n${h.body}`;
}
+14 -7
View File
@@ -12,23 +12,30 @@ export interface MenuItem {
hint: string;
}
/** The everyday author journey. `advanced`/`exit` are control entries. */
/**
* The path, in the order you walk it — create, then dev, then status, then publish. The menu is
* often the first thing a new author ever sees, so its ORDER is the only documentation some of
* them will read. `validate` and `pack` moved to Advanced: `status` says everything `validate`
* says and tells you what to do about it, and `publish` packs for you.
*/
export const PRIMARY_MENU: MenuItem[] = [
{ value: 'create', label: 'Create a plugin', hint: 'Scaffold a new plugin' },
{ value: 'dev', label: 'Run the dev server', hint: 'Live-reload your plugin locally' },
{ value: 'validate', label: 'Validate', hint: 'Check the manifest + layout' },
{ value: 'pack', label: 'Pack', hint: 'Build plugin.zip' },
{ value: 'publish', label: 'Publish', hint: 'Release + open the registry PR' },
{ value: 'advanced', label: 'Advanced…', hint: 'Signing, registry entry, preflight' },
{ value: 'status', label: 'Status', hint: "Where am I? What's left before I can publish?" },
{ value: 'shot', label: 'Screenshot', hint: 'Capture docs/screenshot.png (the registry needs one)' },
{ value: 'publish', label: 'Publish', hint: 'Check → release → open the registry PR' },
{ value: 'advanced', label: 'Advanced…', hint: 'Validate, pack, signing, registry entry' },
{ value: 'exit', label: 'Exit', hint: '' },
];
/** The less-common signing/registry commands, behind the `advanced` entry. */
/** Real commands, just not ones you need in order to publish a plugin. */
export const ADVANCED_MENU: MenuItem[] = [
{ value: 'validate', label: 'Validate', hint: 'The gate: same checks as status, but it exits non-zero' },
{ value: 'pack', label: 'Pack', hint: 'Build plugin.zip without releasing it' },
{ value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' },
{ value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' },
{ value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' },
{ value: 'preflight', label: 'Preflight', hint: 'Run the registry CI checks locally' },
{ value: 'preflight', label: 'Preflight', hint: 'The registry checks that need the release to exist' },
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
{ value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' },
{ value: 'back', label: '← Back', hint: '' },
+15 -3
View File
@@ -13,7 +13,9 @@ import path from 'node:path';
import { createHash } from 'node:crypto';
import { makeZip, type ZipInput } from '../zip.js';
import { injectTrekUi } from '../ui/kit.js';
import { validatePluginDir } from './validate.js';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { blocking } from './checks/types.js';
const MAX_TOTAL = 50 * 1024 * 1024;
const MAX_FILE = 25 * 1024 * 1024;
@@ -56,8 +58,18 @@ function walk(base: string, rel: string, out: ZipInput[]): void {
}
export function packPluginDir(dir: string, outPath: string): PackResult {
const report = validatePluginDir(dir);
if (!report.ok) throw new Error('plugin is not valid:\n - ' + report.errors.join('\n - '));
// Only the gates that make a plugin UNBUILDABLE — a broken manifest, no server entry, a native
// binary. Not the registry's publish gates: packing is how you install a plugin into a local
// TREK to try it, and refusing to build a zip because the README is still a stub would block
// the dev loop over something that only matters at publish time. `validate`/`status`/`publish`
// apply the full set; see checks/types.ts, CheckBlocks.
const report = blocking(runOffline(loadContext(dir)), 'artifact');
if (!report.ok) {
throw new Error(
'plugin is not valid:\n - ' +
report.errors.map((e) => (e.detail ? `${e.title} — ${e.detail}` : e.title)).join('\n - '),
);
}
const outAbs = path.resolve(outPath);
const files: ZipInput[] = [];
+41 -204
View File
@@ -1,217 +1,54 @@
/**
* trek-plugin preflight — run the registry CI checks locally, over the network,
* BEFORE you open a PR. It mirrors TREK-Plugins' validate-entry.mjs (tag→commit,
* manifest parity incl. operatorEgress/requiredAddons/pluginDependencies, artifact
* sha256/size, author-signature shape + verification, native-binary scan) and
* check-readme.mjs (required sections, real prose, a resolving screenshot, permission
* parity), so you catch what CI would reject without a round-trip through review.
* trek-plugin preflight — run the registry's CI checks locally, BEFORE you open a PR.
*
* That mirroring is the whole contract: a gate the registry has and preflight doesn't is
* a false green, and an author trusts a green. Keep the two in step.
* This used to be the ONLY place the registry's real rules were applied, which made it the place
* authors discovered their README was unwritten — over the network, against a pushed tag, after
* an immutable GitHub release had been cut. The rules now live in ./checks/ and most of them fire
* offline from `status` and `validate`, long before any of that. What preflight adds is the last
* mile: the things that can only be known once the tag, the release and the registry exist.
*
* Dependency-free: global fetch + Node built-ins + our own zip reader.
* The contract has not changed, and now covers the whole check registry:
* A GATE THE REGISTRY HAS AND WE DON'T IS A FALSE GREEN, and an author trusts a green.
* test/checks-parity.test.ts is what keeps this honest.
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { listZipNames } from '../zip.js';
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from './verify-signature.js';
import { loadContext } from './checks/context.js';
import { runAll } from './checks/index.js';
import type { RegistryEntry } from './checks/types.js';
const REQUIRED_HEADINGS = ['what it does', 'screenshots', 'permissions', 'setup'];
const PLACEHOLDER_RE = [/\{\{[^}]*\}\}/, /\bREPLACE_ME\b/i, /\bDescribe (what|the)\b/i, /\byour-name\/trek-plugin/i];
const MIN_PROSE = 400;
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
export type { RegistryEntry as Entry, RegistryEntryVersion as EntryVersion } from './checks/types.js';
export interface PluginDependency { id: string; version: string }
export interface EntryVersion {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string;
requiredAddons?: string[]; pluginDependencies?: PluginDependency[];
}
export interface Entry {
id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[];
}
export interface PreflightReport { ok: boolean; failures: string[]; passed: string[]; }
function ghToken(): string | undefined {
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
export interface PreflightReport {
ok: boolean;
failures: string[];
passed: string[];
}
function raw(repo: string, sha: string, p: string): string {
return `https://raw.githubusercontent.com/${repo}/${sha}/${p.replace(/^\.?\//, '')}`;
export interface PreflightOptions {
/** Check every version in the entry, not just the newest. */
all?: boolean;
/** The plugin directory, for the offline half. Defaults to cwd. */
dir?: string;
/** Registry repo to check owner binding + signing history against. */
registry?: string;
/** The exact artifact bytes the entry was built from, when the caller still holds them. */
zipBytes?: Buffer;
}
export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Promise<PreflightReport> {
const failures: string[] = [];
const passed: string[] = [];
const fail = (m: string) => failures.push(m);
const ok = (m: string) => passed.push(m);
/** Flatten a check outcome into the one-line strings this command has always printed. */
const line = (o: { title: string; detail?: string; fix?: string }): string =>
(o.detail ? `${o.title} — ${o.detail}` : o.title) + (o.fix ? '\n' + o.fix.split('\n').map((l) => ' ' + l).join('\n') : '');
const token = ghToken();
const ghHeaders: Record<string, string> = { 'User-Agent': 'trek-plugin-preflight', Accept: 'application/vnd.github+json' };
if (token) ghHeaders.Authorization = `Bearer ${token}`;
export async function preflight(entry: RegistryEntry, opts: PreflightOptions = {}): Promise<PreflightReport> {
const ctx = loadContext(opts.dir ?? '.', { entry, zipBytes: opts.zipBytes });
ctx.registry = opts.registry;
ctx.allVersions = opts.all;
// Structural checks that mirror the registry schema (fast, offline).
if (!/^[a-z][a-z0-9-]{2,39}$/.test(entry.id)) fail(`id "${entry.id}" is not a valid slug (^[a-z][a-z0-9-]{2,39}$)`);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) fail(`repo "${entry.repo}" is not "owner/name"`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(entry.type)) fail(`type "${entry.type}" is not integration|page|widget|trip-page`);
// Signature SHAPE (mirrors the registry's checkSignatureShape): a key and a signature
// must be both-present or both-absent, and each must parse. TREK refuses to install a
// half-signed entry, so one is dead on arrival — better to say so now than at review.
for (const p of checkSignatureShape(entry)) fail(p);
const versions = opts.all ? entry.versions : entry.versions.slice(0, 1);
for (const v of versions) {
const tag = `${v.version}`;
// 1. tag exists and resolves to commitSha
try {
const r = await fetch(`https://api.github.com/repos/${entry.repo}/git/refs/tags/${encodeURIComponent(v.gitTag)}`, { headers: ghHeaders });
if (!r.ok) fail(`${tag}: tag ${v.gitTag} not found on GitHub (${r.status}) — push the tag and create the release`);
else {
const ref = (await r.json()) as { object?: { sha?: string; type?: string } };
let sha = ref.object?.sha;
if (ref.object?.type === 'tag' && sha) {
const tr = await fetch(`https://api.github.com/repos/${entry.repo}/git/tags/${sha}`, { headers: ghHeaders });
if (tr.ok) sha = ((await tr.json()) as { object?: { sha?: string } }).object?.sha;
}
if (sha && sha !== v.commitSha) fail(`${tag}: tag ${v.gitTag} points at ${sha.slice(0, 8)}, entry pins ${v.commitSha.slice(0, 8)}`);
else if (sha) ok(`${tag}: tag resolves to the pinned commit`);
}
} catch (e) { fail(`${tag}: tag check failed: ${(e as Error).message}`); }
// 2. manifest parity at the pinned commit (+ collect permissions for the README gate)
let manifestPerms: string[] = [];
try {
const mr = await fetch(raw(entry.repo, v.commitSha, 'trek-plugin.json'), { headers: { 'User-Agent': 'trek-plugin-preflight' } });
if (!mr.ok) fail(`${tag}: trek-plugin.json not found at ${v.commitSha.slice(0, 8)} (${mr.status})`);
else {
const m = JSON.parse(await mr.text()) as Record<string, unknown>;
if (m.id !== entry.id) fail(`${tag}: manifest id "${m.id}" != entry id "${entry.id}"`);
if (m.version !== v.version) fail(`${tag}: manifest version "${m.version}" != entry "${v.version}"`);
if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`);
// apiVersion is OPTIONAL in the manifest: the host defaults it to 1 at install
// (install/manifest.ts) and `entry` defaults it to 1 when building the entry. Compare
// the DEFAULTED values, or a manifest that legally omits it fails here with
// "manifest apiVersion undefined != entry 1" while the registry's CI passes it.
const mApiVersion = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
if (mApiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${mApiVersion} != entry ${v.apiVersion}`);
if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`);
// Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry
// must not understate the plugin's network reach.
const mOperatorEgress = (m as { operatorEgress?: unknown }).operatorEgress === true;
if (mOperatorEgress !== (v.operatorEgress === true)) {
fail(`${tag}: manifest operatorEgress ${mOperatorEgress} != entry ${v.operatorEgress === true}`);
}
manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : [];
if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
const egress = Array.isArray(m.egress) ? (m.egress as string[]) : [];
// Empty egress[] is legal only with operatorEgress (hosts are admin-supplied).
if (!egress.length && !mOperatorEgress) {
fail(`${tag}: http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)`);
}
if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`);
}
if (mOperatorEgress && !manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
fail(`${tag}: operatorEgress declared without an http:outbound permission`);
}
// Dependency parity (mirrors validate-entry.mjs): the enriched entry must mirror the
// manifest, because TREK resolves requiredAddons/pluginDependencies from the INDEX
// before it ever downloads the artifact. An entry that understates them resolves
// against a dependency set the code doesn't actually have.
const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []);
if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
fail(`${tag}: manifest requiredAddons != entry requiredAddons`);
}
const normDeps = (d: unknown) =>
(Array.isArray(d) ? d.map((x) => `${(x as PluginDependency)?.id}@${(x as PluginDependency)?.version}`).sort() : []);
if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) {
fail(`${tag}: manifest pluginDependencies != entry pluginDependencies`);
}
if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`);
}
} catch (e) { fail(`${tag}: manifest parity failed: ${(e as Error).message}`); }
// 3. artifact download → sha256 + size + native-binary scan
try {
const dr = await fetch(v.downloadUrl, { redirect: 'follow', headers: { 'User-Agent': 'trek-plugin-preflight' } });
if (!dr.ok) fail(`${tag}: artifact download failed (${dr.status}) ${v.downloadUrl}`);
else {
const bytes = Buffer.from(await dr.arrayBuffer());
if (bytes.length > v.size + 4096) fail(`${tag}: artifact (${bytes.length}B) larger than declared size (${v.size}B)`);
const sha = createHash('sha256').update(bytes).digest('hex');
if (sha !== v.sha256) fail(`${tag}: sha256 mismatch — release has ${sha.slice(0, 12)}…, entry pins ${v.sha256.slice(0, 12)}… (re-pack + re-upload?)`);
else ok(`${tag}: artifact sha256 matches the release`);
// Author signature over those same bytes. sha256 proves they are what the REGISTRY
// vouches for; the signature proves they came from the AUTHOR's key. The registry's
// CI verifies this and TREK verifies it again at install, so a signature that does
// not check out is an entry nobody can install — and the author would only find out
// at review. We already have the bytes, so it costs nothing to say so now.
if (v.signature && entry.authorPublicKey) {
try {
if (!verifyAuthorSignature(bytes, v.signature, entry.authorPublicKey)) {
fail(`${tag}: author signature does not verify against authorPublicKey — did you sign with a different key, or re-pack after signing?`);
} else ok(`${tag}: author signature verifies`);
} catch (e) {
if (e instanceof SignatureError) fail(`${tag}: signature/key is malformed: ${e.message}`);
else throw e;
}
}
let names: string[] = [];
try { names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; } catch { /* not a zip */ }
if (names.some((n) => NATIVE_RE.test(n))) fail(`${tag}: artifact contains native binaries (.node / binding.gyp / prebuilds)`);
}
} catch (e) { fail(`${tag}: artifact check failed: ${(e as Error).message}`); }
// 4. README quality gate at the pinned commit (mirrors check-readme.mjs)
await readmeGate(entry.repo, v.commitSha, manifestPerms, tag, fail, ok);
}
return { ok: failures.length === 0, failures, passed };
}
async function readmeGate(repo: string, sha: string, perms: string[], tag: string, fail: (m: string) => void, ok: (m: string) => void): Promise<void> {
let md: string;
try {
const r = await fetch(raw(repo, sha, 'README.md'), { headers: { 'User-Agent': 'trek-plugin-preflight' } });
if (!r.ok) { fail(`${tag}: README.md missing at the repo root (${r.status})`); return; }
md = await r.text();
} catch (e) { fail(`${tag}: README fetch failed: ${(e as Error).message}`); return; }
const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1].toLowerCase());
for (const h of REQUIRED_HEADINGS) if (!headings.some((got) => got.includes(h))) fail(`${tag}: README missing required section "## ${h}"`);
for (const re of PLACEHOLDER_RE) { const hit = md.match(re); if (hit) fail(`${tag}: README still has a template placeholder "${hit[0]}"`); }
const prose = md
.replace(/<!--[\s\S]*?-->/g, '').replace(/```[\s\S]*?```/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+.*$/gm, '').replace(/^\s*\|.*$/gm, '')
.replace(/[#>*_`|-]/g, '').replace(/\s+/g, ' ').trim();
if (prose.length < MIN_PROSE) fail(`${tag}: README has too little prose (${prose.length} chars, need ≥ ${MIN_PROSE})`);
const imgs = [
...[...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]),
...[...md.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]),
].filter((u) => !u.startsWith('data:'));
if (!imgs.length) fail(`${tag}: README has no screenshot (at least one image required)`);
else {
let anyOk = false;
for (const src of [...new Set(imgs)]) {
const url = /^https?:\/\//.test(src)
? (src.includes('github.com') && src.includes('/blob/') ? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') : src)
: raw(repo, sha, src);
try {
const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } });
if (r.ok && (r.headers.get('content-type') || '').startsWith('image/')) { anyOk = true; break; }
} catch { /* try next */ }
}
if (!anyOk) fail(`${tag}: no README screenshot resolved to a real image at the pinned commit`);
else ok(`${tag}: README screenshot resolves`);
}
const lower = md.toLowerCase();
const undocumented = perms.filter((p) => !lower.includes(p.toLowerCase()));
if (undocumented.length) fail(`${tag}: permissions not explained in the README: ${undocumented.join(', ')}`);
else if (perms.length) ok(`${tag}: all permissions documented`);
const report = await runAll(ctx);
return {
ok: report.ok,
// Warnings are surfaced by `status`/`validate`; preflight reports only what would REJECT the
// entry, because its whole promise is "this will get through CI".
failures: report.errors.map(line),
passed: report.outcomes.filter((o) => o.status === 'pass').map((o) => (o.detail ? `${o.title} — ${o.detail}` : o.title)),
};
}
+77 -25
View File
@@ -1,9 +1,18 @@
/**
* trek-plugin publish — the whole release in one command: pack → tag + GitHub
* release → preflight (the registry CI checks, locally) → open the registry PR.
* If preflight fails it stops before submitting, so a broken entry never becomes
* a doomed PR. This is the short path; the individual commands still exist for
* when you want a step by hand.
* trek-plugin publish — the whole release in one command:
* check → pack → tag + GitHub release → preflight → open the registry PR.
*
* THE ORDER IS THE POINT. It used to be pack → release → preflight → submit, which meant the
* checks that catch an unwritten README or a missing screenshot ran AFTER the GitHub release had
* been cut. A release is immutable in every way that matters — the registry pins its sha256, so
* overwriting the bytes breaks the checksum for everyone who already installed it — and so an
* author who failed preflight was stuck: their v1.0.0 tag was burned, and the fix was to throw it
* away and cut a v1.0.1 whose only change was the README.
*
* Now step 1 runs every gate that can be answered from the working tree, and NOTHING is packed,
* tagged, pushed or released until they all pass. Preflight still runs (step 4) because the last
* few gates genuinely need the tag and the release to exist — but by then it is checking things
* that are true by construction, not discovering that the README was never written.
*
* Requires `git` + `gh` (authenticated), same as `release`/`submit`.
*/
@@ -13,6 +22,10 @@ import { packPluginDir } from './pack.js';
import { buildEntry } from './entry.js';
import { preflight } from './preflight.js';
import { submitEntry } from './submit.js';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { renderPlain } from './checks/report.js';
import { assertSigningAllowed, inspectSigning, type SigningState } from './signing.js';
import { plainLog, type LogSink } from './ui.js';
function git(dir: string, args: string[], quiet = true): string {
@@ -30,30 +43,63 @@ export async function publishPlugin(opts: {
signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean;
/** Overwrite the artifact on an existing release. Only safe if it was never merged. */
force?: boolean;
/** Skip the local gates. An escape hatch for a re-run, never a good idea on a first publish. */
skipChecks?: boolean;
/**
* A signing state the CLI already looked up (it does, to know whether to offer signing). Passed
* in so a single publish does not hit the registry twice for the same answer.
*/
signing?: SigningState;
/** Progress sink. Defaults to the plain console.error lines (CI parity). */
log?: LogSink;
}): Promise<{ prUrl: string }> {
const dir = path.resolve(opts.dir);
const log = opts.log ?? plainLog;
const step = (n: number, msg: string) => log(`[${n}/4] ${msg}`);
const step = (n: number, msg: string) => log(`[${n}/5] ${msg}`);
// 1. Pack
step(1, 'Packing the artifact…');
// 1. The local gates — BEFORE anything is packed, tagged or released.
if (opts.skipChecks) {
log('[1/5] Local checks skipped (--no-checks).');
} else {
step(1, 'Checking the plugin against the registry gates…');
const ctx = loadContext(dir);
const report = runOffline(ctx);
if (!report.ok) {
const detail = renderPlain({ ...report, warnings: [] });
throw new Error(
`${report.errors.length} check${report.errors.length === 1 ? '' : 's'} would be rejected by the registry.\n\n` +
detail +
'\n\nNothing was packed, tagged or released. Fix these and re-run — or `trek-plugin status` for the full picture.',
);
}
// Signing is not just a preference once a plugin has shipped signed: TREK refuses an unsigned
// update to it, so this publish is doomed. preflight would catch it — at step 4, AFTER the
// immutable release is cut, which is precisely the trap this command's reorder exists to close.
// So it belongs here, before anything exists to be wasted.
const id = typeof ctx.manifest?.id === 'string' ? ctx.manifest.id : '';
if (id) {
assertSigningAllowed(opts.signing ?? (await inspectSigning(id, { registry: opts.registry })), opts.signKeyPath);
}
log(` ✓ ${report.outcomes.filter((o) => o.status === 'pass').length} checks passed`);
}
// 2. Pack
step(2, 'Packing the artifact…');
const zip = path.join(dir, 'plugin.zip');
const packed = packPluginDir(dir, zip);
log(` ✓ ${packed.files.length} files, ${packed.size} bytes`);
// 2. Tag (if needed) + push + GitHub release with the artifact attached
step(2, `Tagging ${opts.tag} + creating the GitHub release…`);
// 3. Tag (if needed) + push + GitHub release with the artifact attached
step(3, `Tagging ${opts.tag} + creating the GitHub release…`);
if (!tagExists(dir, opts.tag)) git(dir, ['tag', opts.tag]);
try { git(dir, ['push', 'origin', opts.tag]); } catch {
throw new Error(`could not push tag ${opts.tag} — is "origin" your plugin's GitHub repo and are you authenticated? (git push origin ${opts.tag})`);
}
// A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the
// bytes of a release that is already in the registry breaks the checksum for everyone
// who has that version — they can no longer install or update it. Refuse by default.
// (The old code blanket-caught every `gh release create` failure — auth, network, a bad
// repo — and turned it into a --clobber upload.)
// A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes of a
// release that is already in the registry breaks the checksum for everyone who has that version
// — they can no longer install or update it. Refuse by default.
if (releaseExists(opts.repo, opts.tag)) {
if (!opts.force) {
throw new Error(
@@ -69,25 +115,31 @@ export async function publishPlugin(opts: {
}
log(` ✓ release ${opts.tag} on ${opts.repo}`);
// 3. Build the entry, then run the registry CI checks locally
// 4. Build the entry, then run the gates that need the tag and the release to exist
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
if (opts.skipPreflight) {
log('[3/4] Preflight skipped (--no-preflight).');
log('[4/5] Preflight skipped (--no-preflight).');
} else {
step(3, 'Preflight — running the registry CI checks…');
const rep = await preflight(entry);
step(4, 'Preflight — the gates that need the release to exist…');
const rep = await preflight(entry, { dir, registry: opts.registry });
for (const f of rep.failures) log(' ✗ ' + f);
if (!rep.ok) throw new Error(`preflight found ${rep.failures.length} problem(s) — fix these and re-run (nothing was submitted). Did you push your code to ${opts.repo} before publishing?`);
if (!rep.ok) {
throw new Error(
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
`The release is cut, so fix these, commit, and re-run against a NEW tag (or --force if this one was never merged).\n` +
`Did you push your code to ${opts.repo} before publishing?`,
);
}
log(` ✓ all ${rep.passed.length} checks passed`);
}
// 4. Open the registry PR
step(4, 'Opening the registry PR…');
// 5. Open the registry PR
step(5, 'Opening the registry PR…');
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
log(' ✓ done');
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were
// computed from — a re-pack on another machine or SDK version can differ (CRLF, walk
// order), so anyone re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
log(` artifact kept at ${zip}`);
return { prUrl };
}
+176
View File
@@ -0,0 +1,176 @@
/**
* trek-plugin shot — capture docs/screenshot.png.
*
* The screenshot is the one registry gate an author cannot fake, cannot satisfy by writing more
* words, and — until now — could not do anything about from the CLI at all. The scaffold's README
* linked `./docs/screenshot.png`, nothing created it, `validate` passed anyway (it only looked for
* an image LINK), and the truth arrived from CI after the release was immutable.
*
* So: boot the dev server, render the plugin in the same themed, sandboxed frame TREK uses
* (`/preview`), and write a 1600×900 PNG. One command, and the hardest gate is done.
*
* Playwright is NOT a dependency of this SDK — it is ~300 MB of browser and most authors never
* run this. It is imported lazily, and if it is absent we say exactly how to get it rather than
* failing with a module-resolution stack trace.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { readJsonFile } from './json.js';
/** The store card is 16:9 and crops the edges — 1600×900 leaves margin and stays under a MB. */
const WIDTH = 1600;
const HEIGHT = 900;
export interface ShotOptions {
port?: number;
out?: string;
/** Screenshot an already-running dev server instead of booting one. */
noServe?: boolean;
/** Render in TREK's dark theme instead of light. */
dark?: boolean;
}
export class ShotError extends Error {}
const portOpen = (port: number): Promise<boolean> =>
new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1');
socket.on('connect', () => { socket.destroy(); resolve(true); });
socket.on('error', () => resolve(false));
});
async function waitForPort(port: number, timeoutMs = 30_000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (await portOpen(port)) return true;
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
/**
* Playwright, or a message that tells you how to get it.
*
* Resolved from the PLUGIN's directory first, not ours. A bare `import('playwright')` resolves
* relative to this file — which works when the SDK is a devDependency of the plugin (node walks up
* into the plugin's node_modules), and silently does not when the SDK is installed globally. The
* author would have run exactly the `npm i -D playwright` we told them to and still be told it was
* missing. So ask the plugin where its own modules live, and only then fall back to ours.
*/
async function loadChromium(pluginDir: string): Promise<{ launch: (o?: unknown) => Promise<Chromium> }> {
type PW = { chromium: { launch: (o?: unknown) => Promise<Chromium> } };
const fromPlugin = createRequire(path.join(pluginDir, 'package.json'));
for (const resolve of [() => fromPlugin.resolve('playwright'), () => 'playwright']) {
try {
const specifier = resolve();
const pw = (await import(specifier.startsWith('/') ? pathToFileURL(specifier).href : specifier)) as unknown as PW;
if (pw.chromium) return pw.chromium;
} catch {
/* try the next resolution */
}
}
throw new ShotError(
'trek-plugin shot needs Playwright, which is not installed.\n\n' +
' npm i -D playwright && npx playwright install chromium\n\n' +
'It is not a dependency of the SDK — it ships a whole browser, and most authors never run this.\n' +
'If you would rather not install it: take a 16:9 screenshot yourself (1600×900 is ideal — the\n' +
'store card crops the edges) and save it as docs/screenshot.png.',
);
}
// Minimal structural types — we never import Playwright's, so it stays a soft dependency.
interface Page {
goto(url: string, o?: unknown): Promise<unknown>;
emulateMedia(o: { colorScheme: 'light' | 'dark' }): Promise<void>;
waitForTimeout(ms: number): Promise<void>;
screenshot(o: { path: string; fullPage?: boolean }): Promise<unknown>;
}
interface Browser {
newPage(o?: unknown): Promise<Page>;
close(): Promise<void>;
}
type Chromium = Browser;
export async function runShot(dir: string, opts: ShotOptions = {}): Promise<{ out: string; width: number; height: number }> {
const root = path.resolve(dir);
const manifestPath = path.join(root, 'trek-plugin.json');
if (!fs.existsSync(manifestPath)) throw new ShotError(`no trek-plugin.json in ${dir} — is this a plugin directory?`);
const manifest = readJsonFile<{ type?: string; id?: string }>(manifestPath);
const type = String(manifest.type ?? '');
// An integration plugin has no UI to render. Refusing outright would be unhelpful — it still
// NEEDS a screenshot, it just isn't one we can take. Say what to shoot instead.
if (type === 'integration') {
throw new ShotError(
'An integration plugin has no UI, so there is nothing for the dev server to render.\n\n' +
'It still needs a screenshot — the registry requires one and the store card shows it. Capture the\n' +
'TREK surface your plugin CHANGES: the notification it sends, the badge it adds to a place, the row\n' +
'it contributes to a table, its settings page under Admin → Plugins.\n\n' +
'Save it as docs/screenshot.png (16:9, 1600×900 is ideal — the card crops the edges).',
);
}
const port = opts.port ?? 4317;
const out = path.resolve(root, opts.out ?? path.join('docs', 'screenshot.png'));
const chromium = await loadChromium(root);
let dev: ChildProcess | undefined;
let browser: Browser | undefined;
try {
if (!opts.noServe) {
if (await portOpen(port)) {
throw new ShotError(
`port ${port} is already in use.\n` +
`If that is your dev server, pass --no-serve to shoot it. Otherwise pick another port with --port.`,
);
}
// Reuse the real dev server rather than reimplementing the preview frame — a screenshot of
// something that isn't what `dev` serves would be worse than no screenshot at all.
//
// Spawn the CLI, not dev.js: dev.js only EXPORTS runDev, it has no entry block, so running
// it as a script imports a module and exits — and the only symptom is a 30-second wait.
dev = spawn(process.execPath, [path.join(import.meta.dirname, 'trek-plugin.js'), 'dev', root, '--port', String(port)], {
stdio: ['ignore', 'ignore', 'pipe'],
});
// Keep the child's stderr. If it dies (a plugin that throws on load, a port clash), that
// message is the whole diagnosis — swallowing it leaves the author staring at a timeout.
let devErr = '';
dev.stderr?.on('data', (b: Buffer) => { devErr += b.toString(); });
if (!(await waitForPort(port))) {
throw new ShotError(
`the dev server did not come up on port ${port} within 30s.` +
(devErr.trim() ? `\n\n${devErr.trim()}` : '\n\nRun `trek-plugin dev` yourself to see why.'),
);
}
} else if (!(await portOpen(port))) {
throw new ShotError(`--no-serve was passed but nothing is listening on port ${port}`);
}
browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: 2 });
await page.emulateMedia({ colorScheme: opts.dark ? 'dark' : 'light' });
// chrome=0 strips the dev toolbar and the "runs sandboxed at an opaque origin" note, and
// centres the plugin. Without it the store card would show the theme picker of the tool that
// took the screenshot, which is not a picture of anybody's plugin.
const theme = opts.dark ? 'dark' : 'light';
await page.goto(`http://localhost:${port}/preview?chrome=0&theme=${theme}`, { waitUntil: 'networkidle' });
// The plugin's own UI loads inside a sandboxed iframe and then fetches. Give it a beat, or we
// photograph a skeleton — a screenshot of a loading spinner passes every gate and helps nobody.
await page.waitForTimeout(1200);
fs.mkdirSync(path.dirname(out), { recursive: true });
await page.screenshot({ path: out });
return { out, width: WIDTH, height: HEIGHT };
} finally {
await browser?.close().catch(() => {});
dev?.kill();
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Signing, proposed rather than buried.
*
* `keygen` lived under "Advanced…" in the menu and `--sign` was a flag you had to already know
* about, so the default path — the one almost everyone takes — shipped unsigned, and nothing
* anywhere mentioned that signing existed. That is the wrong default to arrive at by accident,
* because of what signing actually means (server registry.service.ts, verifySignatureAndTofu):
*
* - Unsigned throughout is fine. The sha256 pin is the only guarantee, and TREK accepts it.
* - UNSIGNED → SIGNED later is allowed. Nothing is pinned until a signed version installs, so
* adding a key at v1.4.0 breaks nobody. Signing late is a real option, not a lost cause.
* - SIGNED → UNSIGNED is refused forever (SIGNATURE_MISSING) on every instance that already has
* the plugin. And rotating the key needs a registry maintainer override AND an admin re-trust
* on every instance.
*
* So it is a one-way door you may walk through late, but never back out of. That makes the right
* prompt an honest recommendation with the consequence attached — not a scare, and not silence.
*
* The other half of this file is the guard. If a plugin was published signed and you publish
* unsigned, the registry refuses it — and `preflight` caught that at step 4, AFTER the immutable
* GitHub release was cut. Same trap as the one `publish`'s reorder closed. It belongs in step 1.
*/
import fs from 'node:fs';
import { defaultKeyPath, generateKeypair } from './sign.js';
import { DEFAULT_REGISTRY } from './checks/network.js';
import { isInteractive, promptConfirm, note, logWarn, logSuccess } from './ui.js';
export interface SigningState {
/** A signing key exists on this machine. */
hasKey: boolean;
keyPath: string;
/** This plugin is ALREADY PUBLISHED SIGNED — TREK refuses an unsigned update to it. */
publishedSigned: boolean;
/** The key it was published under, when publishedSigned. */
publishedKey?: string;
}
/**
* What we know about this plugin's signing situation.
*
* The registry lookup is best-effort: offline, or a registry we cannot read, yields
* `publishedSigned: false`. That is the safe direction — we may fail to *insist* on signing, and
* preflight still catches it before the PR — but we never wrongly insist on it, which would block
* an author who has done nothing wrong.
*/
export async function inspectSigning(
pluginId: string,
opts: { registry?: string; keyPath?: string } = {},
): Promise<SigningState> {
const keyPath = opts.keyPath ?? defaultKeyPath();
const state: SigningState = { hasKey: fs.existsSync(keyPath), keyPath, publishedSigned: false };
const registry = opts.registry ?? DEFAULT_REGISTRY;
try {
const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/registry/plugins/${pluginId}.json`, {
headers: { 'User-Agent': 'trek-plugin' },
});
if (!r.ok) return state; // not published yet — nothing to be bound by
const published = (await r.json()) as { authorPublicKey?: string };
if (published.authorPublicKey) {
state.publishedSigned = true;
state.publishedKey = published.authorPublicKey;
}
} catch {
/* offline, or an unreadable registry — see the doc comment */
}
return state;
}
/**
* The guard: refuse to start a publish that is going to be rejected for a signing reason.
*
* Runs BEFORE anything is packed, tagged or released. Getting this wrong is expensive in a way
* most check failures are not: the release is immutable, so an author who learns at step 4 that
* their update had to be signed has burned the tag.
*/
export function assertSigningAllowed(state: SigningState, signKeyPath: string | undefined): void {
if (!state.publishedSigned || signKeyPath) return;
throw new Error(
'this plugin was published SIGNED, so this update must be signed too.\n\n' +
'TREK pins the author key on first install. An unsigned update to a signed plugin is REFUSED on every\n' +
'instance that already has it — publishing this would strand every existing user on the version they have.\n\n' +
(state.hasKey
? `Re-run with --sign (your key is at ${state.keyPath}).`
: `No signing key found at ${state.keyPath}.\n` +
'If you have a backup of the key you published with, restore it there. If you have LOST it, the key\n' +
'cannot be rotated without a registry maintainer override (the `allow-key-change` label).'),
);
}
/**
* Ask, in a terminal, whether to sign — and create the key if there isn't one.
*
* Returns the key path to sign with, or undefined to publish unsigned. Only ever called
* interactively: a script or CI run that did not pass --sign gets exactly the behaviour it always
* got, because prompting a pipeline is how you hang a pipeline.
*/
export async function proposeSigning(state: SigningState): Promise<string | undefined> {
if (!isInteractive()) return undefined;
// Already published signed: this is not a choice, and pretending it is would be a lie. Say so
// and sign. (assertSigningAllowed handles the no-key case with a much longer explanation.)
if (state.publishedSigned) {
if (!state.hasKey) return undefined; // let the guard produce the real error
logWarn('This plugin was published signed — TREK refuses an unsigned update, so this release will be signed.');
return state.keyPath;
}
if (state.hasKey) {
const sign = await promptConfirm({
message: `Sign this release with your key? (${state.keyPath})`,
initialValue: true,
});
return sign ? state.keyPath : undefined;
}
note(
'A signature proves the artifact came from YOU, not just that its bytes match what the registry saw.\n' +
'TREK pins your key the first time someone installs a signed version, and refuses anything not signed\n' +
'with it after that.\n\n' +
'It is a one-way door — but you can walk through it late. Publishing unsigned now and signing at v1.4.0\n' +
'breaks nobody. Publishing SIGNED and then unsigned breaks everybody.\n\n' +
'If you sign: back the key up. Losing it means you cannot ship an update to your own plugin without a\n' +
'registry maintainer override.',
'Signing',
);
const create = await promptConfirm({
message: 'Create a signing key and sign this release? (recommended)',
initialValue: true,
});
if (!create) {
logWarn('Publishing unsigned. You can add signing later with `trek-plugin keygen`, and it will break nobody.');
return undefined;
}
const { publicKey } = generateKeypair(state.keyPath);
logSuccess(`Signing key written to ${state.keyPath}`);
note(
`${publicKey}\n\n` +
'This is your authorPublicKey — it goes into the registry entry, and `publish` puts it there for you.\n' +
'BACK UP THE PRIVATE KEY. It is the only thing that can sign an update to this plugin.',
'Back this up',
);
return state.keyPath;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* trek-plugin status [dir] — where am I, and what do I do next?
*
* The command an author runs when they are lost, which — before this existed — was most of the
* time. It runs every offline gate the registry enforces, prints the whole journey as a
* checklist, and names ONE next command.
*
* It deliberately never exits non-zero. `status` is for orientation; `validate` is the gate. A
* command you are afraid to run because it might fail your build is not a command you reach for
* when you are stuck.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { renderChecklist, nextStep, summaryLine } from './checks/report.js';
/** Has this version already been tagged locally? Cheap, offline — no fetch, no API. */
function isTagged(dir: string, version: string): boolean {
for (const tag of [`v${version}`, version]) {
try {
execFileSync('git', ['-C', dir, 'rev-parse', `${tag}^{commit}`], { stdio: 'pipe' });
return true;
} catch {
/* not this one */
}
}
return false;
}
/** Are there uncommitted changes? The registry grades the COMMIT, so a dirty tree is worth saying. */
function isDirty(dir: string): boolean {
try {
return execFileSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().length > 0;
} catch {
return false; // not a git repo — `publish` will say so far more usefully than we can here
}
}
export interface StatusOptions {
/** Show passing checks too, not just problems. Defaults to true — the whole point is the map. */
verbose?: boolean;
colour?: boolean;
}
export function runStatus(dir: string, opts: StatusOptions = {}): { ok: boolean; text: string } {
const root = path.resolve(dir);
const out: string[] = [];
if (!fs.existsSync(path.join(root, 'trek-plugin.json'))) {
return {
ok: false,
text:
` No plugin here.\n\n ${path.relative(process.cwd(), root) || '.'} has no trek-plugin.json.\n\n` +
' next → trek-plugin create',
};
}
const ctx = loadContext(root);
const report = runOffline(ctx);
const id = typeof ctx.manifest?.id === 'string' ? ctx.manifest.id : path.basename(root);
const type = typeof ctx.manifest?.type === 'string' ? ctx.manifest.type : '?';
const version = typeof ctx.manifest?.version === 'string' ? ctx.manifest.version : '?';
out.push('');
out.push(` ${id} · ${type} · v${version}`);
out.push(renderChecklist(report, { verbose: opts.verbose ?? true, colour: opts.colour }));
// Git state is not a "gate" — it is context. A plugin can be perfectly valid and simply not
// released yet, and calling that a failure would be a lie.
out.push('');
out.push(' Repo');
out.push(' ' + (isTagged(root, version) ? `· v${version} is tagged locally` : `· v${version} is not tagged yet`));
if (isDirty(root)) {
out.push(' ! uncommitted changes — the registry grades the COMMIT, not your working tree');
}
const next = nextStep(report);
out.push('');
out.push(' ' + summaryLine(report));
out.push(next.command ? ` next → ${next.command}` : ` next → ${next.hint}`);
out.push('');
return { ok: report.ok, text: out.join('\n') };
}
+161 -75
View File
@@ -2,38 +2,33 @@
/**
* `trek-plugin <command>` — the plugin author CLI (#plugins).
*
* create [name] [--type t] [--interactive] scaffold a plugin (wizard if no name)
* [--template blank|notification-channel] type: integration|page|widget|trip-page
* [--egress host,host] [--required-addons a,b]
* dev [dir] [--port 4317] run locally with hot reload
* validate [dir] check the manifest + layout
* pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
* keygen [--key file] create an Ed25519 signing key
* sign [zip] [--key file] print a signature + public key for an artifact
* entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry
* [--merge entry.json] [--sign [key]] [--out f]
* preflight [dir] --repo o/n --tag vX run the registry CI checks locally
* submit [dir] --repo o/n --tag vX open the registry PR for you
* [--sign [key]] [--registry o/n] [--draft]
* release [dir] --repo o/n --tag vX pack -> gh release -> print entry
* [--sign [key]] [--merge entry.json]
* publish [dir] --repo o/n --tag vX the lot: pack -> tag+release ->
* [--sign [key]] [--no-preflight] preflight -> open the registry PR
* This file is the ROUTER, and nothing more: it parses flags, decides interactive vs not, and
* hands off. The per-command help lives in ./help.ts (single source of truth, so `--help` and the
* docs cannot drift), and the rules every command is judged by live in ./checks/.
*
* The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha
* or hand-write the registry JSON.
* The path is four commands — create → dev → status → publish. The other nine are steps one of
* those already does, and are listed under "Also" rather than presented as things you must learn.
*
* The goal: never hand-compute sha256/size/commitSha, never hand-write the registry JSON, and
* never discover a problem after the release that pins it has been cut.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { validatePluginDir } from './validate.js';
import { packPluginDir } from './pack.js';
import { buildEntry } from './entry.js';
import { scaffold, interactiveScaffold } from './create.js';
import { scaffold, interactiveScaffold, nextStepsAfterCreate, type WidgetSlot } from './create.js';
import { runDev } from './dev.js';
import { runStatus } from './status.js';
import { runShot } from './shot.js';
import { preflight } from './preflight.js';
import { submitEntry } from './submit.js';
import { publishPlugin } from './publish.js';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { renderPlain } from './checks/report.js';
import { topLevelHelp, commandHelp, VERSION_LINE } from './help.js';
import { inspectSigning, proposeSigning, type SigningState } from './signing.js';
import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js';
import { readJsonFile } from './json.js';
import {
@@ -74,17 +69,19 @@ type Flags = Record<string, string>;
* instruction is worse than refusing it, so unknown flags are now an error.
*/
const COMMAND_FLAGS: Record<string, readonly string[]> = {
create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons'],
create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons', 'icon', 'slot'],
dev: ['port'],
status: [],
shot: ['port', 'out', 'dark', 'no-serve'],
validate: [],
pack: ['out', 'json'],
keygen: ['key'],
sign: ['key'],
entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out'],
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all'],
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry'],
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep'],
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key'],
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'force'],
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force'],
};
function assertKnownFlags(command: string, f: Flags): void {
@@ -129,56 +126,99 @@ async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string;
return { repo: repo.trim(), tag: tag.trim() };
}
const USAGE = 'usage: trek-plugin <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]';
const USAGE = 'usage: trek-plugin <create|dev|status|publish|...> (`trek-plugin help` for the full list)';
const HELP = `${USAGE}
create [name] [--type integration|page|widget|trip-page] scaffold a plugin (wizard if no name)
[--template blank|notification-channel] [--interactive]
[--author a] [--description d] [--permissions "a b"]
[--egress host,host] [--required-addons a,b]
dev [dir] [--port 4317] run locally with hot reload
validate [dir] check the manifest + layout
pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
keygen [--key file] create an Ed25519 signing key
sign [zip] [--key file] print a signature + public key for an artifact
entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry
[--dir d] [--commit c] [--asset a] [--merge entry.json] [--sign [key]] [--out f]
preflight [dir] --repo o/n --tag vX run the registry CI checks locally
[--entry e] [--zip z] [--commit c] [--sign [key]] [--all]
submit [dir] --repo o/n --tag vX open the registry PR for you
[--sign [key]] [--registry o/n] [--branch b] [--draft] [--keep]
release [dir] --repo o/n --tag vX pack -> gh release -> print entry
[--out f] [--notes n] [--sign [key]] [--merge entry.json]
publish [dir] --repo o/n --tag vX the lot: pack -> tag+release ->
[--sign [key]] [--no-preflight] preflight -> open the registry PR
[--registry o/n] [--notes n] [--draft]
[--force] overwrite an existing release's artifact
(breaks the sha256 pin if it was ever merged)
The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha
or hand-write the registry JSON.`;
/** The SDK's own version, for `--version` and the help banner. */
function sdkVersion(): string {
try {
const pkg = readJsonFile<{ version?: string }>(path.join(import.meta.dirname, '..', '..', 'package.json'));
return pkg.version ?? '0.0.0';
} catch {
return '0.0.0';
}
}
async function main(): Promise<void> {
// Advisory only (update-notifier): prints from cache on stderr, refreshes in the
// background, never blocks. `dev` never returns, so this runs up front.
notifySdkUpdate();
// Before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown.
// Help is not an error: it goes to stdout and exits 0.
if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flags.help) {
console.log(HELP);
// `--version` used to fall through to the `else` branch and print the usage line with exit 2,
// which is a strange way to answer a question every CLI is expected to answer.
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
console.log(VERSION_LINE(sdkVersion()));
return;
}
// Handled before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown.
// Help is not an error: it goes to stdout and exits 0.
// trek-plugin help the path, plus where to find everything else
// trek-plugin help <cmd> that command's own page
// trek-plugin <cmd> --help the same page — this is what people actually type
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
const topic = pos[0];
if (topic) {
const page = commandHelp(topic);
if (!page) fail(`no such command: ${topic}\n ${USAGE}`);
console.log(page);
return;
}
console.log(topLevelHelp(sdkVersion()));
return;
}
if (cmd && flags.help) {
const page = commandHelp(cmd);
if (!page) fail(`no such command: ${cmd}\n ${USAGE}`);
console.log(page);
return;
}
if (!cmd) {
// Bare invocation: a menu in a terminal, the usage line for scripts.
if (!isInteractive()) { console.error(USAGE); process.exit(2); }
const chosen = await runMenu();
if (chosen) await dispatch(chosen, {}, []);
if (chosen) {
// The menu used to dispatch with EMPTY positionals, so picking "Validate" or "Pack"
// silently ran against the cwd — which is very often not a plugin at all. Ask.
const dir = await promptPluginDir(chosen);
await dispatch(chosen, {}, dir ? [dir] : []);
}
return;
}
await dispatch(cmd, flags, pos);
}
/** The plugin's id, or '' if this isn't a plugin directory. Never throws — the caller has better errors. */
function readPluginId(dir: string): string {
try {
const m = readJsonFile<{ id?: unknown }>(path.join(path.resolve(dir), 'trek-plugin.json'));
return typeof m.id === 'string' ? m.id : '';
} catch {
return '';
}
}
/** Commands that operate on a plugin directory, and so need one when the menu launched them. */
const DIR_COMMANDS = new Set(['dev', 'status', 'validate', 'pack', 'shot', 'publish', 'preflight', 'submit', 'release']);
async function promptPluginDir(command: string): Promise<string | undefined> {
if (!DIR_COMMANDS.has(command)) return undefined;
// If the cwd IS a plugin, that is overwhelmingly what was meant — don't make them type a dot.
if (fs.existsSync(path.join(process.cwd(), 'trek-plugin.json'))) return undefined;
const dir = await promptText({
message: 'Which plugin directory?',
placeholder: './my-plugin',
defaultValue: '.',
validate: (v) => {
const d = (v || '.').trim();
if (!fs.existsSync(d)) return `${d} does not exist`;
if (!fs.existsSync(path.join(d, 'trek-plugin.json'))) return `${d} has no trek-plugin.json`;
return undefined;
},
});
return dir.trim() || '.';
}
async function dispatch(command: string, f: Flags, positional: string[]): Promise<void> {
const tui = isInteractive();
assertKnownFlags(command, f);
@@ -195,22 +235,47 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
egress: listFlag(f.egress),
requiredAddons: listFlag(f['required-addons']),
template: f.template as 'blank' | 'notification-channel' | undefined,
icon: f.icon,
slot: f.slot as WidgetSlot | undefined,
});
console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`);
// The SAME text the wizard and the `create-trek-plugin` bin print. These three used to
// name three different next commands.
console.log(nextStepsAfterCreate(path.join(process.cwd(), name)));
} else if (command === 'dev') {
if (tui) intro(`trek-plugin dev — ${positional[0] || '.'}`);
await runDev(positional[0] || '.', { port: f.port ? Number(f.port) : undefined });
} else if (command === 'validate') {
const r = validatePluginDir(positional[0] || '.');
} else if (command === 'status') {
// Never exits non-zero: `status` is for orientation, not gating. A command you are afraid
// to run because it might fail your build is not one you reach for when you are stuck.
const r = runStatus(positional[0] || '.', { colour: tui });
console.log(r.text);
} else if (command === 'shot') {
const dir = positional[0] || '.';
if (tui) {
for (const w of r.warnings) logWarn(w);
if (!r.ok) { for (const e of r.errors) logError(e); outro('✗ plugin has errors'); process.exit(1); }
outro('✓ plugin is valid');
const s = spinner();
s.start('Rendering your plugin');
try {
const r = await runShot(dir, { port: f.port ? Number(f.port) : undefined, out: f.out, dark: !!f.dark, noServe: !!f['no-serve'] });
s.stop(`Wrote ${path.relative(process.cwd(), r.out) || r.out} (${r.width}×${r.height})`);
outro('next → trek-plugin status');
} catch (e) {
s.stop('Could not take the screenshot');
throw e;
}
} else {
for (const w of r.warnings) console.warn('warning: ' + w);
if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); }
console.log('✓ plugin is valid');
const r = await runShot(dir, { port: f.port ? Number(f.port) : undefined, out: f.out, dark: !!f.dark, noServe: !!f['no-serve'] });
console.log(`Wrote ${r.out} (${r.width}×${r.height})`);
}
} else if (command === 'validate') {
const report = runOffline(loadContext(positional[0] || '.'));
const text = renderPlain(report);
if (text) console.error(text);
if (!report.ok) {
// Don't just fail — say where to see the whole picture.
console.error(`\n${report.errors.length} of these would be rejected by the registry. \`trek-plugin status\` shows the full checklist.`);
process.exit(1);
}
if (tui) outro('✓ plugin is valid'); else console.log('✓ plugin is valid');
} else if (command === 'pack') {
const r = packPluginDir(positional[0] || '.', f.out || 'plugin.zip');
const rel = path.relative(process.cwd(), r.artifact) || r.artifact;
@@ -218,23 +283,25 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
console.log(JSON.stringify(r, null, 2)); // machine output — never decorated
} else if (tui) {
note([...r.files, '', `sha256: ${r.sha256}`, `size: ${r.size}`].join('\n'), `Packed ${r.files.length} files → ${rel}`);
logInfo('Upload plugin.zip to your release, then run `npx trek-plugin-sdk entry`.');
// This used to point at `entry` — the low-level, assemble-the-PR-by-hand path — rather than
// at `publish`, which does the release, the entry and the PR and gets the ORDER right.
logInfo('Install this into a local TREK to try it. To ship it: `trek-plugin publish`.');
} else {
console.log(`Packed ${r.files.length} files -> ${rel}`);
for (const file of r.files) console.log(' ' + file);
console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`);
console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.');
console.log('\nInstall this into a local TREK to try it. To ship it: `trek-plugin publish --repo <owner/name> --tag <vX.Y.Z>`.');
}
} else if (command === 'keygen') {
const keyPath = f.key || defaultKeyPath();
const { publicKey } = generateKeypair(keyPath);
if (tui) {
note(`Signing key written to ${keyPath}\nKeep it safe + BACK IT UP — losing it means you can't ship signed updates.\n\nauthorPublicKey (goes in your registry entry):\n${publicKey}`, 'Signing key');
logInfo('Sign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).');
logInfo('`trek-plugin publish` will now offer to sign with this key — you do not need to pass --sign.');
} else {
console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`);
console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`);
console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).');
console.log('\nPass --sign to `trek-plugin publish` to sign a release with it (interactively, publish offers).');
}
} else if (command === 'sign') {
const zip = positional[0] || 'plugin.zip';
@@ -289,15 +356,34 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
}
} else if (command === 'publish') {
const { repo, tag } = await ensureRepoTag(f, 'publish needs --repo <owner/name> and --tag <vX.Y.Z>');
const dir = positional[0] || '.';
// Signing used to be a flag you had to already know about, with `keygen` buried under
// "Advanced…" — so the default path shipped unsigned and never mentioned that signing existed.
// Offer it, once, at the moment it is actually a decision. A script that did not pass --sign
// gets exactly the behaviour it always got: prompting a pipeline is how you hang a pipeline.
let signKeyPath = signKey(f);
let signing: SigningState | undefined;
if (tui && !signKeyPath) {
const id = readPluginId(dir);
if (id) {
signing = await inspectSigning(id, { registry: f.registry, keyPath: f.key });
signKeyPath = await proposeSigning(signing);
}
}
if (tui) {
note(`repo ${repo}\ntag ${tag}\ndir ${positional[0] || '.'}`, 'Publish');
note(
`repo ${repo}\ntag ${tag}\ndir ${dir}\nsign ${signKeyPath ? `yes (${signKeyPath})` : 'no — unsigned'}`,
'Publish',
);
const ok = await promptConfirm({ message: 'Create the GitHub release and open the registry PR?', initialValue: true });
if (!ok) { outro('Cancelled — nothing was published.'); return; }
}
const { prUrl } = await publishPlugin({
dir: positional[0] || '.', repo, tag,
signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft,
notes: f.notes, skipPreflight: !!f['no-preflight'], force: !!f.force,
dir, repo, tag,
signKeyPath, signing, registry: f.registry, draft: !!f.draft,
notes: f.notes, skipPreflight: !!f['no-preflight'], skipChecks: !!f['no-checks'], force: !!f.force,
now: new Date().toISOString(),
log: tui ? clackLogSink : undefined,
});
@@ -347,7 +433,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
}
} else {
console.error(USAGE);
console.error(`unknown command: ${command}\n${USAGE}`);
process.exit(2);
}
}
+40 -95
View File
@@ -1,15 +1,20 @@
#!/usr/bin/env node
/**
* trek-plugin validate [dir] (#plugins, M6). Runs the SAME manifest checks the
* registry CI runs, plus a light README sanity check — so a local pass predicts
* a CI pass. Returns a structured result; the CLI prints + exits non-zero on
* failure.
* trek-plugin validate [dir] — the gate.
*
* Runs every check the registry enforces that can be answered from the working tree, and exits
* non-zero if any of them would reject the plugin. That is a much stronger claim than it used to
* be: this file previously ran about a fifth of the registry's rules, and demoted the two that
* bite hardest — a bad `icon` and an unwritten README — to warnings. A green validate was not
* evidence of anything, and the first real verdict arrived from `preflight`, over the network,
* against a tag whose GitHub release was already immutable.
*
* The rules themselves live in ./checks/, shared with `status`, `preflight` and `publish`. This
* file is now only a printer and an exit code.
*/
import fs from 'node:fs';
import path from 'node:path';
import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js';
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
import { readJsonFile } from './json.js';
import { loadContext } from './checks/context.js';
import { runOffline } from './checks/index.js';
import { renderPlain } from './checks/report.js';
export interface ValidateReport {
ok: boolean;
@@ -17,95 +22,35 @@ export interface ValidateReport {
warnings: string[];
}
/**
* One string per problem, for callers that want a flat list rather than a rendered checklist.
*
* The `fix` is folded in, not dropped. Several checks summarise in `detail` ("2 problems") and put
* the specifics in `fix` ("• tag \"Flight Tracking\" is not a lowercase slug…") — so a flat list
* built from title + detail alone would report a failure without ever saying WHAT failed, which
* is the exact uselessness this rework is about.
*/
function flatten(outcomes: { title: string; detail?: string; fix?: string }[]): string[] {
return outcomes.map((o) => {
const head = o.detail ? `${o.title} — ${o.detail}` : o.title;
return o.fix ? `${head}\n${o.fix}` : head;
});
}
export function validatePluginDir(dir: string): ValidateReport {
const errors: string[] = [];
const warnings: string[] = [];
const manifestPath = path.join(dir, 'trek-plugin.json');
if (!fs.existsSync(manifestPath)) return { ok: false, errors: ['no trek-plugin.json in ' + dir], warnings };
let manifestId = '';
try {
const raw = readJsonFile<Record<string, unknown>>(manifestPath);
const res = validateManifest(raw);
errors.push(...res.errors);
manifestId = res.manifest?.id ?? String(raw.id ?? '');
// dir name should match the id
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
warnings.push(`directory name should equal the plugin id "${manifestId}"`);
}
// A `trek` of "*" installs on every TREK ever released and every one still to come.
// It is legal — a plugin may honestly have no version-specific surface — but it is far
// more often an author who didn't want to think about it, and it opts them out of the
// one mechanism that stops their plugin running on a host it was never tested against.
if (isUnboundedRange(res.manifest?.trek ?? '')) {
warnings.push('trek: "*" claims support for every TREK version, including ones that do not exist yet — pin a range like ">=3.2.0 <4.0.0"');
}
// A well-formed but unrecognised addon id is not an error (the plugin may target
// a newer TREK), but it can never enable here — surface it as a warning.
for (const a of res.manifest?.requiredAddons ?? []) {
if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`);
}
// Same deal for `icon`: TREK resolves it against lucide at render time and falls
// back to Blocks on a name it can't find, so a typo is silent in the UI. Warn
// rather than error — the list is a snapshot, and a newer lucide may have names
// this SDK predates.
const icon = typeof raw.icon === 'string' ? raw.icon : '';
if (icon && !LUCIDE_ICON_NAMES.has(icon)) {
warnings.push(`icon: "${icon}" is not a known lucide icon name on this SDK version — TREK will fall back to Blocks`);
}
// TREK renders its UI with lucide icons only. It STRIPS emojis from the declarative
// text your hooks return (badges, columns, warnings, PDF sections, map/calendar/photo
// labels) and from notifications, so emojis in that text simply vanish at render.
// Emojis in the manifest name/description are the tell-tale sign — nudge the author
// to use the `icon` field with a lucide name instead.
const EMOJI_RE = /[\p{Extended_Pictographic}\u{1F1E6}-\u{1F1FF}]/u;
if (EMOJI_RE.test(String(raw.name ?? '')) || EMOJI_RE.test(String(raw.description ?? ''))) {
warnings.push('name/description contains emojis — TREK uses lucide icons and strips emojis from plugin-rendered text + notifications. Use the declarative `icon` field (a lucide name) instead of emojis in labels.');
}
} catch (e) {
errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e));
}
// README sanity
const readmePath = path.join(dir, 'README.md');
if (!fs.existsSync(readmePath)) {
errors.push('README.md is missing');
} else {
const md = fs.readFileSync(readmePath, 'utf8');
if (!/!\[[^\]]*\]\([^)]+\)/.test(md)) warnings.push('README has no screenshot (registry requires at least one)');
if (/\{\{[^}]*\}\}|REPLACE_ME|Describe (what|the)|One sentence:/i.test(md)) {
warnings.push('README still contains template placeholders — fill it in before publishing');
}
}
// Client UI: a raw <select> without the design kit falls back to the OS-drawn
// dropdown, which can't match TREK. The kit auto-upgrades selects, so nudge the
// author to inline it (or opt a field out on purpose).
const clientHtml = path.join(dir, 'client', 'index.html');
if (fs.existsSync(clientHtml)) {
const html = fs.readFileSync(clientHtml, 'utf8');
const rawSelect = /<select(?![^>]*\bdata-trek-native\b)(\s|>)/i.test(html);
const usesKit = html.includes('<!-- trek:ui -->') || html.includes('data-trek-ui');
if (rawSelect && !usesKit) {
warnings.push('client/index.html has a <select> but does not inline the design kit — native dropdowns will not match TREK. Add the <!-- trek:ui --> marker so selects are auto-styled, or mark a field data-trek-native to opt out.');
}
}
// Server entry present
if (!fs.existsSync(path.join(dir, 'server', 'index.js'))) errors.push('server/index.js is missing (build your plugin first)');
return { ok: errors.length === 0, errors, warnings };
const report = runOffline(loadContext(dir));
return {
ok: report.ok,
errors: flatten(report.errors),
warnings: flatten(report.warnings),
};
}
if (process.argv[1] && process.argv[1].endsWith('validate.js')) {
const dir = process.argv[2] || '.';
const r = validatePluginDir(dir);
for (const w of r.warnings) console.warn('warning: ' + w);
if (r.ok) {
console.log('✓ plugin is valid');
} else {
for (const e of r.errors) console.error('error: ' + e);
process.exit(1);
}
const report = runOffline(loadContext(dir));
const text = renderPlain(report);
if (text) console.error(text);
if (!report.ok) process.exit(1);
console.log('✓ plugin is valid');
}