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