Files
TREK/plugin-sdk/src/cli/checks/offline.ts
T
jubnl 1e6ab069cd Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
2026-09-02 18:42:16 +02:00

641 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, SETTING_FIELD_KEYS } from '../../manifest.js';
// The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the
// only list that counts — and permissions.ts for why dev's egress guard uses the same one.
import { grantedHosts } from '../../permissions.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,
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) : []);
// ── 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 ">=4.0.0 <5.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 manifestSettingsKnownKeys: OfflineCheck = {
id: 'manifest.settings-known-keys',
stage: 'manifest',
depth: 'offline',
severity: 'warn',
title: 'settings fields carry only attributes the SDK knows',
run: (c) => {
const settings = c.manifest?.settings;
const fields = (Array.isArray(settings) ? settings : []).filter(
(s): s is Record<string, unknown> => !!s && typeof s === 'object',
);
if (!fields.length) return skip();
const unknown: string[] = [];
for (const f of fields) {
for (const k of Object.keys(f)) {
if (!(SETTING_FIELD_KEYS as readonly string[]).includes(k)) unknown.push(`${String(f.key ?? '?')}.${k}`);
}
}
return unknown.length
? fail(
unknown.join(', '),
`TREK stores only the attributes it knows and SILENTLY DROPS the rest at install — a typo ("defalt")\nor an attribute from a newer SDK simply does nothing, with no diagnostic anywhere.\nKnown: ${SETTING_FIELD_KEYS.join(', ')}.`,
)
: pass(`${fields.length} field(s) clean`);
},
};
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`);
},
};
/**
* The store cover: `docs/screenshot.png`, ON DISK.
*
* Two generations of this check drifted lenient. The first regexed the README for an image
* *link* and passed on the scaffold's dangling `![screenshot](./docs/screenshot.png)`. The
* second resolved README image paths — but the registry (check-readme.mjs, TREK-Plugins #40)
* does not read the README for this gate at all: it fetches **exactly `docs/screenshot.png`**
* at the pinned commit, because that precise path is what the store card loads
* (`raw…/<commit>/docs/screenshot.png`). A README whose only images had other names went
* green here and red in CI, after the release was immutable. Check the file the store reads.
*/
const docsScreenshot: OfflineCheck = {
id: 'docs.screenshot',
stage: 'docs',
depth: 'offline',
severity: 'error',
title: 'docs/screenshot.png exists (the store cover)',
run: (c) => {
if (c.exists('docs/screenshot.png')) return pass('docs/screenshot.png');
return fail(
'docs/screenshot.png does not exist',
'The registry fetches exactly this path at the pinned commit — it is the store card image, and a README\n' +
'that links other image names does not satisfy the gate. Capture a 16:9 shot (1600×900 is ideal — the\n' +
'card crops the edges) and commit it as docs/screenshot.png.',
'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((x, y) => (x < y ? -1 : x > y ? 1 : 0));
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((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : []);
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,
manifestSettingsKnownKeys,
manifestNoEmoji,
codeServerEntry,
codeEgressReachable,
codeEgressDeclared,
codeDesignKit,
docsReadmePresent,
docsReadmeSections,
docsReadmePlaceholders,
docsReadmeProse,
docsScreenshot,
docsPermissionsExplained,
releaseSignatureShape,
releaseNoNativeBinaries,
releaseEntryParity,
releaseArtifactHash,
];
export type { CheckContext };