rvlib-shipkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ // Contract fulfillness engine — evaluates ContractManifests against a repo,
2
+ // emitting wire-protocol lines. Enforcement is BOTH ways:
3
+ // - declared venue with an incomplete folder → FAIL per missing artifact
4
+ // - undeclared venue whose detectors fire → FAIL (config can't lie,
5
+ // folders can't rot)
6
+ // - exempt venues skip VISIBLY with their reason; never silence
7
+ // - 'always' contracts with gate 'public-surface': full severity once the
8
+ // repo has a public surface, everything demoted to WARN before.
9
+ // Every FAIL carries a fix: pointer = docsBase + manifest.owner.
10
+ import { existsSync, readFileSync, statSync } from 'fs'
11
+ import { join } from 'path'
12
+ import type { ArtifactRule, ContractEnv, ContractManifest } from './contract-types.ts'
13
+ import type { Line } from '../core/wire.ts'
14
+
15
+ export type ContractsConfig = {
16
+ root: string
17
+ /** venue ids the repo declares it ships on (config side of both-ways drift) */
18
+ declared: string[]
19
+ /** social channel list — handed to custom rules via ContractEnv */
20
+ social: string[]
21
+ /** repo has a public surface (deploy script or any declared venue) — gates 'always' contracts */
22
+ publicSurface: boolean
23
+ /** venue → reason; skips the venue VISIBLY */
24
+ exempt?: Record<string, string>
25
+ /** prefix for fix: pointers, e.g. a docs checkout path or URL */
26
+ docsBase?: string
27
+ }
28
+
29
+ export type ContractOutcome = { venue: string; status: 'ok' | 'fail' | 'warn' | 'exempt' | 'not-applicable'; problems: number }
30
+ export type ContractsResult = { lines: Line[]; exitCode: 0 | 1; outcomes: ContractOutcome[] }
31
+
32
+ // PNG dims from the IHDR header — bytes 16..23 big-endian, after the
33
+ // 8-byte signature + IHDR length/type. No image dependency.
34
+ export function pngDims(path: string): { width: number; height: number } | null {
35
+ try {
36
+ const fd = readFileSync(path)
37
+ if (fd.length < 24) return null
38
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
39
+ for (let i = 0; i < sig.length; i++) if (fd[i] !== sig[i]) return null
40
+ return { width: fd.readUInt32BE(16), height: fd.readUInt32BE(20) }
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ const abs = (root: string, rel: string): string => join(root, rel)
47
+
48
+ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]> {
49
+ const problems: string[] = []
50
+ switch (rule.kind) {
51
+ case 'file': {
52
+ const p = abs(env.root, rule.path)
53
+ if (!existsSync(p)) problems.push(`${rule.path} missing`)
54
+ else if (!statSync(p).isFile()) problems.push(`${rule.path} is not a file`)
55
+ else if (rule.nonEmpty && statSync(p).size === 0) problems.push(`${rule.path} is empty`)
56
+ break
57
+ }
58
+ case 'png': {
59
+ const p = abs(env.root, rule.path)
60
+ if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${rule.width}x${rule.height}`); break }
61
+ const dims = pngDims(p)
62
+ if (dims == null) problems.push(`${rule.path} is not a readable png`)
63
+ else if (dims.width !== rule.width || dims.height !== rule.height)
64
+ problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${rule.width}x${rule.height}`)
65
+ break
66
+ }
67
+ case 'glob': {
68
+ const matches = [...new Bun.Glob(rule.pattern).scanSync({ cwd: env.root, onlyFiles: true })]
69
+ if (matches.length < rule.min) { problems.push(`${rule.pattern}: ${matches.length} file(s) — need >= ${rule.min}`); break }
70
+ if (rule.width != null && rule.height != null)
71
+ for (const m of matches) {
72
+ const dims = pngDims(abs(env.root, m))
73
+ if (dims == null || dims.width !== rule.width || dims.height !== rule.height)
74
+ problems.push(`${m} is ${dims == null ? 'not a readable png' : `${dims.width}x${dims.height}`} — need ${rule.width}x${rule.height}`)
75
+ }
76
+ break
77
+ }
78
+ case 'md': {
79
+ const p = abs(env.root, rule.path)
80
+ if (!existsSync(p)) { problems.push(`${rule.path} missing`); break }
81
+ const text = readFileSync(p, 'utf8')
82
+ if (text.trim() === '') { problems.push(`${rule.path} is empty`); break }
83
+ for (const needle of rule.mustContain) if (!text.includes(needle)) problems.push(`${rule.path} lacks required '${needle}'`)
84
+ break
85
+ }
86
+ case 'dir': {
87
+ const p = abs(env.root, rule.path)
88
+ if (!existsSync(p) || !statSync(p).isDirectory()) problems.push(`${rule.path}/ missing`)
89
+ break
90
+ }
91
+ case 'custom': {
92
+ problems.push(...(await rule.probe(env)))
93
+ break
94
+ }
95
+ }
96
+ return problems
97
+ }
98
+
99
+ function detectorFires(m: ContractManifest, cfg: ContractsConfig): string | null {
100
+ for (const d of m.detect) {
101
+ if (d.kind === 'dirExists') {
102
+ if (existsSync(abs(cfg.root, d.path))) return `${d.path}/ exists`
103
+ } else if (d.kind === 'fileContains') {
104
+ const p = abs(cfg.root, d.path)
105
+ if (existsSync(p) && new RegExp(d.pattern).test(readFileSync(p, 'utf8'))) return `${d.path} matches /${d.pattern}/`
106
+ } else {
107
+ const pkgPath = abs(cfg.root, 'package.json')
108
+ if (!existsSync(pkgPath)) continue
109
+ try {
110
+ const scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}
111
+ const re = new RegExp(d.pattern)
112
+ const hit = Object.keys(scripts).find((k) => re.test(k))
113
+ if (hit != null) return `package.json script '${hit}'`
114
+ } catch { /* unparseable package.json is another tool's FAIL */ }
115
+ }
116
+ }
117
+ return null
118
+ }
119
+
120
+ export async function evaluateContracts(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
121
+ const lines: Line[] = []
122
+ const outcomes: ContractOutcome[] = []
123
+ let anyFail = false
124
+ const env: ContractEnv = {
125
+ root: cfg.root,
126
+ venues: cfg.declared,
127
+ social: cfg.social,
128
+ exists: (rel) => existsSync(abs(cfg.root, rel)),
129
+ }
130
+ const fix = (m: ContractManifest, text: string): Line[] => [
131
+ { kind: 'fix-fail', text: `${text} — ${(cfg.docsBase ?? '') + m.owner}` },
132
+ ]
133
+
134
+ for (const m of manifests) {
135
+ const exemptReason = cfg.exempt?.[m.venue]
136
+ if (exemptReason != null) {
137
+ lines.push({ kind: 'skip', text: `${m.venue} exempt: "${exemptReason}"` })
138
+ outcomes.push({ venue: m.venue, status: 'exempt', problems: 0 })
139
+ continue
140
+ }
141
+ const declared = cfg.declared.includes(m.venue)
142
+ if (m.appliesTo === 'declared' && !declared) {
143
+ const signal = detectorFires(m, cfg)
144
+ if (signal != null) {
145
+ anyFail = true
146
+ lines.push({ kind: 'fail', text: `${m.venue} ships (${signal}) but venue is not declared — declare it or remove the signal` })
147
+ lines.push(...fix(m, 'declare + fill the contract'))
148
+ outcomes.push({ venue: m.venue, status: 'fail', problems: 1 })
149
+ } else {
150
+ outcomes.push({ venue: m.venue, status: 'not-applicable', problems: 0 })
151
+ }
152
+ continue
153
+ }
154
+ // demote everything to WARN on ungated 'always' contracts (no public surface yet)
155
+ const warnAll = m.appliesTo === 'always' && m.gate === 'public-surface' && !cfg.publicSurface
156
+ let problems = 0
157
+ let warned = 0
158
+ for (const rule of m.artifacts) {
159
+ const found = await checkRule(rule, env)
160
+ const asWarn = warnAll || rule.warnOnly === true
161
+ for (const p of found) {
162
+ if (asWarn) {
163
+ warned++
164
+ lines.push({ kind: 'warn', text: `${m.venue} ${p}${warnAll ? ' (no public surface yet — WARN not FAIL)' : ''}` })
165
+ } else {
166
+ problems++
167
+ lines.push({ kind: 'fail', text: `${m.venue} ${p}` })
168
+ }
169
+ }
170
+ }
171
+ // ONE fix pointer per contract per severity — ten missing capsules
172
+ // must not print ten identical fix lines
173
+ if (problems > 0) lines.push(...fix(m, 'contract'))
174
+ if (warned > 0) lines.push({ kind: 'fix-warn', text: (cfg.docsBase ?? '') + m.owner })
175
+ if (problems > 0) {
176
+ anyFail = true
177
+ outcomes.push({ venue: m.venue, status: 'fail', problems })
178
+ } else if (warned > 0) {
179
+ outcomes.push({ venue: m.venue, status: 'warn', problems: warned })
180
+ } else {
181
+ lines.push({ kind: 'ok', text: `${m.venue} ${m.dir}/ complete (${m.artifacts.length} rule(s))` })
182
+ outcomes.push({ venue: m.venue, status: 'ok', problems: 0 })
183
+ }
184
+ }
185
+ return { lines, exitCode: anyFail ? 1 : 0, outcomes }
186
+ }
@@ -0,0 +1,15 @@
1
+ // normative WHAT for .rv-discord/ — a project's public Discord server
2
+ // contract (promoted out of .rv-social/discord/ 2026-07-21; posts stay there).
3
+ import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
+
5
+ export default defineContract({
6
+ venue: 'discord',
7
+ dir: '.rv-discord',
8
+ owner: 'distribute/social.md',
9
+ appliesTo: 'declared',
10
+ detect: [dirExists('.rv-discord')],
11
+ artifacts: [
12
+ md('.rv-discord/server.md', { mustContain: ['invite'] }),
13
+ png('.rv-discord/icon.png', 512, 512),
14
+ ],
15
+ })
@@ -0,0 +1,19 @@
1
+ // normative WHAT for .rv-identity/ — every repo with a public surface owns
2
+ // its brand source + committed favicon/logo/OG outputs. gate: FAIL once a
3
+ // public surface exists (deploy.sh or any declared venue), WARN before.
4
+ import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
5
+
6
+ export default defineContract({
7
+ venue: 'identity',
8
+ dir: '.rv-identity',
9
+ owner: 'build/assets.md',
10
+ appliesTo: 'always',
11
+ gate: 'public-surface',
12
+ detect: [],
13
+ artifacts: [
14
+ file('.rv-identity/brand.ts'),
15
+ png('public/favicon.png', 512, 512),
16
+ png('public/og.png', 1200, 630),
17
+ glob('public/logo.*', { min: 1 }),
18
+ ],
19
+ })
@@ -0,0 +1,16 @@
1
+ // normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
2
+ import { defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
+
4
+ export default defineContract({
5
+ venue: 'itch',
6
+ dir: '.rv-itch',
7
+ owner: 'distribute/itch.md',
8
+ appliesTo: 'declared',
9
+ detect: [script('^(up|push):itch$'), fileContains('deploy.sh', 'butler push'), dirExists('.rv-itch')],
10
+ artifacts: [
11
+ // one AI-disclosure wording everywhere — page.md is its canonical home
12
+ md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
13
+ png('.rv-itch/cover.png', 630, 500),
14
+ glob('.rv-itch/screenshots/*.png', { min: 3 }),
15
+ ],
16
+ })
@@ -0,0 +1,18 @@
1
+ // normative WHAT for .rv-social/ (posts ONLY — the Discord server contract is
2
+ // discord.contract.ts). Declared = the repo config lists social channels.
3
+ import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
+
5
+ export default defineContract({
6
+ venue: 'social',
7
+ dir: '.rv-social',
8
+ owner: 'distribute/social.md',
9
+ appliesTo: 'declared',
10
+ detect: [dirExists('.rv-social')],
11
+ artifacts: [
12
+ custom('declared-channel-dirs', (env) =>
13
+ env.social
14
+ .filter((ch) => !env.exists(`.rv-social/${ch}`))
15
+ .map((ch) => `.rv-social/${ch}/ missing — declared social channel '${ch}' has no post folder`),
16
+ ),
17
+ ],
18
+ })
@@ -0,0 +1,28 @@
1
+ // normative WHAT for .rv-steam/ — the WHY + workflow live in the sibling page.
2
+ // Capsule sizes as of 2026-07 — Valve churns them: re-verify at use, bump here.
3
+ import { defineContract, dir, dirExists, file, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
+
5
+ export default defineContract({
6
+ venue: 'steam',
7
+ dir: '.rv-steam',
8
+ owner: 'distribute/steam.md',
9
+ appliesTo: 'declared',
10
+ detect: [script('^(up|push):steam$'), fileContains('deploy.sh', 'run_app_build'), dirExists('.rv-steam')],
11
+ artifacts: [
12
+ // store.md carries the same AI-disclosure wording as .rv-itch/page.md
13
+ md('.rv-steam/store.md', { mustContain: ['## AI disclosure'] }),
14
+ png('.rv-steam/capsules/main.png', 1232, 706),
15
+ png('.rv-steam/capsules/header.png', 920, 430),
16
+ png('.rv-steam/capsules/small.png', 462, 174),
17
+ png('.rv-steam/capsules/vertical.png', 748, 896),
18
+ png('.rv-steam/capsules/library.png', 600, 900),
19
+ png('.rv-steam/capsules/library-hero.png', 3840, 1240),
20
+ png('.rv-steam/capsules/library-logo.png', 1280, 720),
21
+ png('.rv-steam/capsules/community-icon.png', 184, 184),
22
+ // .ico, dims not png-parseable — presence-checked only
23
+ file('.rv-steam/capsules/client-icon.ico'),
24
+ glob('.rv-steam/screenshots/*.png', { min: 5, width: 1920, height: 1080 }),
25
+ // vdf scripts only exist once the appid does — nag, don't block
26
+ dir('.rv-steam/vdf', { warnOnly: true }),
27
+ ],
28
+ })
@@ -0,0 +1,63 @@
1
+ // CheckCtx — the per-check handle a probe's logic receives: outcome lines
2
+ // (wire.ts kinds), guarded fetch/exec helpers, resolved-tool lookup.
3
+ import { existsSync } from 'fs'
4
+ import type { Line } from './wire.ts'
5
+
6
+ // thrown by t.fetch on 429 — the suite converts it to WARN, never FAIL
7
+ export class RateLimited extends Error {}
8
+ // thrown by t.tool when its defineRequiredTool check failed — that check
9
+ // already FAILed the run; the dependent check notes it skipped, no double FAIL
10
+ export class ToolUnresolved extends Error {}
11
+
12
+ export class CheckCtx {
13
+ lines: Line[] = []
14
+ failed = false
15
+ warned = false
16
+ constructor(private toolPaths: Map<string, string>) {}
17
+
18
+ ok(msg: string): void { this.lines.push({ kind: 'ok', text: msg }) }
19
+ note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
20
+ warn(msg: string, fix?: string): void {
21
+ this.warned = true
22
+ this.lines.push({ kind: 'warn', text: msg })
23
+ if (fix != null) this.lines.push({ kind: 'fix-warn', text: fix })
24
+ }
25
+ fail(msg: string, fix?: string): void {
26
+ this.failed = true
27
+ this.lines.push({ kind: 'fail', text: msg })
28
+ if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
29
+ }
30
+
31
+ // path resolved by an earlier defineRequiredTool check (declaration order)
32
+ tool(name: string): string {
33
+ const p = this.toolPaths.get(name)
34
+ if (p == null) throw new ToolUnresolved(`tool '${name}' unresolved — its defineRequiredTool check failed or is declared after this check`)
35
+ return p
36
+ }
37
+
38
+ // fetch with a default timeout; 429 → RateLimited → WARN upstream
39
+ async fetch(url: string, init?: RequestInit & { timeoutMs?: number }): Promise<Response> {
40
+ const res = await fetch(url, { ...init, signal: init?.signal ?? AbortSignal.timeout(init?.timeoutMs ?? 10_000) })
41
+ if (res.status === 429) throw new RateLimited(`${new URL(url).host} → 429 rate-limited`)
42
+ return res
43
+ }
44
+
45
+ // spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
46
+ // timeoutMs, SIGKILL 2s later (a child ignoring SIGTERM must not hang us)
47
+ async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
48
+ const head = cmd[0]
49
+ if (head == null) return { code: -1, out: 'empty command', timedOut: false }
50
+ const bin = head.includes('/') ? head : Bun.which(head)
51
+ if (bin == null || !existsSync(bin)) return { code: -1, out: `${head}: not found`, timedOut: false }
52
+ const p = Bun.spawn([bin, ...cmd.slice(1)], { stdout: 'pipe', stderr: 'pipe', env: opts?.env as Record<string, string>, cwd: opts?.cwd })
53
+ let timedOut = false
54
+ const ms = opts?.timeoutMs ?? 15_000
55
+ const timer = setTimeout(() => { timedOut = true; p.kill() }, ms)
56
+ const killer = setTimeout(() => p.kill('SIGKILL'), ms + 2000)
57
+ const out = (await new Response(p.stdout).text()) + (await new Response(p.stderr).text())
58
+ const code = await p.exited
59
+ clearTimeout(timer)
60
+ clearTimeout(killer)
61
+ return { code, out, timedOut }
62
+ }
63
+ }
@@ -0,0 +1,179 @@
1
+ // CheckSuite — declare tools / secrets / checks, then `await suite.run()`;
2
+ // renders the v0 wire contract (wire.ts) with exit code.
3
+ //
4
+ // The TTL cache is the API-spam guard: a passing check with `remainValidFor`
5
+ // replays from `<cacheDir>/<name>.json` without touching the network. Only
6
+ // clean passes are cached; WARN/FAIL evict. Busts: version bump, secret
7
+ // rotation (key hashes the secret value), `<freshEnv>=1`.
8
+ //
9
+ // Secret VALUES never enter this module's state beyond the injected
10
+ // getSecret call + a truncated hash for cache-busting.
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
14
+ import { fmtAge, formatLine, parseDuration, type Line } from './wire.ts'
15
+
16
+ type Logic = (t: CheckCtx) => unknown
17
+ type Prep = { skip?: string; cacheExtra?: string }
18
+ type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic }
19
+ type CacheFile = Record<string, { version: number; key: string; at: number; lines: Line[] }>
20
+
21
+ export type GetSecret = (name: string, field: 'get' | 'account') => string | null
22
+
23
+ export type SecretSpec = {
24
+ remainValidFor?: string | number
25
+ version?: number
26
+ /** appended to the unstored skip message — a POINTER to where to mint (URL, file, or doc section), never inline mint steps */
27
+ skipHint: string
28
+ }
29
+
30
+ export type CheckSuiteOpts = {
31
+ name: string
32
+ cacheDir: string
33
+ now?: () => number
34
+ /** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
35
+ getSecret?: GetSecret
36
+ /** env var that busts the cache when set (consumer names it) */
37
+ freshEnv?: string
38
+ }
39
+
40
+ export class CheckSuite {
41
+ readonly name: string
42
+ private runners: Runner[] = []
43
+ private toolPaths = new Map<string, string>()
44
+ private cacheDir: string
45
+ private now: () => number
46
+ private getSecret: GetSecret
47
+ private freshEnv: string
48
+
49
+ constructor(opts: CheckSuiteOpts) {
50
+ this.name = opts.name
51
+ this.cacheDir = opts.cacheDir
52
+ this.now = opts.now ?? Date.now
53
+ this.getSecret = opts.getSecret ?? (() => null)
54
+ this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
55
+ }
56
+
57
+ addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; logic: Logic }): void {
58
+ this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, logic: spec.logic })
59
+ }
60
+
61
+ defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
62
+ this.addCheck({
63
+ name,
64
+ logic: async (t) => {
65
+ // explicit candidate paths beat ambient PATH — a stale global (e.g. tsc 4.x) must not shadow the pinned one
66
+ const bin = opts.candidates?.find((c) => existsSync(c)) ?? Bun.which(name)
67
+ if (bin == null) return t.fail(`${name} not installed`, opts.fix)
68
+ this.toolPaths.set(name, bin)
69
+ if (opts.probe == null) return t.ok(`${name} present`)
70
+ const p = await t.exec([bin, ...opts.probe.args])
71
+ const first = p.out.trim().split('\n')[0] ?? ''
72
+ if (p.code !== 0 || (opts.probe.expect != null && !opts.probe.expect.test(p.out.trim())))
73
+ return t.fail(`${name} present but broken (${first.slice(0, 120)})`, opts.fix)
74
+ // version-ish summary, not the whole banner (some CLIs append build date + ref)
75
+ t.ok(`${name} ${(first.split(',')[0] ?? first).slice(0, 80)}`)
76
+ },
77
+ })
78
+ }
79
+
80
+ defineRequiredSecret(spec: SecretSpec & { name: string; account?: false; validate: (value: string, t: CheckCtx) => unknown }): void
81
+ defineRequiredSecret(spec: SecretSpec & { name: string; account: true; validate: (cred: { value: string; account: string }, t: CheckCtx) => unknown }): void
82
+ defineRequiredSecret(spec: SecretSpec & { name: string[]; validate: (values: string[], t: CheckCtx) => unknown }): void
83
+ defineRequiredSecret(spec: SecretSpec & { name: string | string[]; account?: boolean; validate: (v: never, t: CheckCtx) => unknown }): void {
84
+ const names = [spec.name].flat()
85
+ const primary = names[0]
86
+ if (primary == null) throw new Error('defineRequiredSecret: name must be non-empty')
87
+ // probes may run without typechecking — enforce the required pointer at define time too
88
+ if (typeof spec.skipHint !== 'string' || spec.skipHint.length === 0)
89
+ throw new Error(`defineRequiredSecret(${primary}): skipHint is required — a pointer to where to mint (URL, file, or doc section)`)
90
+ const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
91
+ let payload: unknown
92
+ this.runners.push({
93
+ // bare secret name — this shows up in human-facing WARN/FAIL lines
94
+ name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
95
+ ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
96
+ version: spec.version ?? 1,
97
+ prep: () => {
98
+ const values = names.map((n) => this.getSecret(n, 'get'))
99
+ const account = spec.account === true ? this.getSecret(primary, 'account') : undefined
100
+ const missing = names.filter((_, i) => values[i] == null)
101
+ if (spec.account === true && account == null) missing.push(`${primary} (account field)`)
102
+ if (missing.length > 0)
103
+ return { skip: `${missing.join(', ')} unstored — ${spec.skipHint}` }
104
+ payload = spec.account === true ? { value: values[0], account } : Array.isArray(spec.name) ? values : values[0]
105
+ // secret rotation busts the cache: key = hash of the value(s)
106
+ const material = [...(values as string[]), account ?? ''].join('\0')
107
+ return { cacheExtra: new Bun.CryptoHasher('sha256').update(material).digest('hex').slice(0, 16) }
108
+ },
109
+ logic: (t) => validate(payload, t),
110
+ })
111
+ }
112
+
113
+ // test seam — run() prints + exits; execute() returns the structured result
114
+ async execute(): Promise<{ lines: Line[]; exitCode: number }> {
115
+ const cachePath = join(this.cacheDir, `${this.name}.json`)
116
+ let cache: CacheFile = {}
117
+ try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
118
+ let cacheDirty = false
119
+
120
+ const out: Line[] = []
121
+ const skipped: string[] = []
122
+ let ran = 0
123
+ let anyFail = false
124
+
125
+ for (const r of this.runners) {
126
+ const prep = r.prep?.() ?? {}
127
+ if (prep.skip != null) { skipped.push(prep.skip); continue }
128
+ ran++
129
+ const key = prep.cacheExtra ?? ''
130
+ const entry = cache[r.name]
131
+ const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
132
+ && entry != null && entry.version === r.version && entry.key === key
133
+ && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
134
+ if (fresh) {
135
+ const age = fmtAge(this.now() - entry.at)
136
+ out.push(...entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${age} ago)` } : l)))
137
+ continue
138
+ }
139
+ const t = new CheckCtx(this.toolPaths)
140
+ try {
141
+ await r.logic(t)
142
+ } catch (e) {
143
+ if (e instanceof RateLimited) t.warn(`${r.name} not validated this run (${e.message}) — retry later`)
144
+ else if (e instanceof ToolUnresolved) t.note(`${r.name} skipped (${e.message})`)
145
+ else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
146
+ }
147
+ out.push(...t.lines)
148
+ if (t.failed) anyFail = true
149
+ if (r.ttlMs > 0) {
150
+ if (!t.failed && !t.warned) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
151
+ // a WARN/FAIL evicts the stale pass — a later run must re-probe,
152
+ // not replay an ok recorded before the breakage
153
+ else if (entry != null) { delete cache[r.name]; cacheDirty = true }
154
+ }
155
+ }
156
+
157
+ // contract: `skip:` lines ONLY when no check ran at all (whole probe is
158
+ // moot without the creds); otherwise a neutral note so passing probes
159
+ // are not relabeled 'check skip'
160
+ if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
161
+ else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
162
+
163
+ if (cacheDirty) {
164
+ // prune entries for renamed/removed checks — the file must not accrete
165
+ const known = new Set(this.runners.map((r) => r.name))
166
+ for (const k of Object.keys(cache)) if (!known.has(k)) delete cache[k]
167
+ mkdirSync(this.cacheDir, { recursive: true })
168
+ writeFileSync(cachePath, JSON.stringify(cache))
169
+ }
170
+ return { lines: out, exitCode: anyFail ? 1 : 0 }
171
+ }
172
+
173
+ async run(): Promise<never> {
174
+ const { lines, exitCode } = await this.execute()
175
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
176
+ for (const l of lines) console.log(formatLine(l, color))
177
+ process.exit(exitCode)
178
+ }
179
+ }