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.
- package/CHANGELOG.md +21 -0
- package/README.md +58 -0
- package/package.json +19 -0
- package/src/asks/asks-queue.ts +95 -0
- package/src/cli/shipkit-asks.ts +26 -0
- package/src/cli/shipkit-check.ts +26 -0
- package/src/cli/shipkit-ci.ts +30 -0
- package/src/cli/shipkit-contracts.ts +26 -0
- package/src/cli/shipkit-init.ts +40 -0
- package/src/cli/shipkit-wiki.ts +74 -0
- package/src/cli.ts +28 -0
- package/src/contracts/contract-manifests.ts +10 -0
- package/src/contracts/contract-types.ts +77 -0
- package/src/contracts/contracts-engine.ts +186 -0
- package/src/contracts/manifests/discord.contract.ts +15 -0
- package/src/contracts/manifests/identity.contract.ts +19 -0
- package/src/contracts/manifests/itch.contract.ts +16 -0
- package/src/contracts/manifests/social.contract.ts +18 -0
- package/src/contracts/manifests/steam.contract.ts +28 -0
- package/src/core/check-ctx.ts +63 -0
- package/src/core/check-suite.ts +179 -0
- package/src/core/task-runner.ts +274 -0
- package/src/core/wire.ts +36 -0
- package/src/repo-config.ts +119 -0
- package/src/shipkit.ts +23 -0
- package/src/wiki/graph-page.ts +169 -0
- package/src/wiki/lint-md.ts +24 -0
- package/src/wiki/md-graph.ts +6 -0
- package/src/wiki/scan-md.ts +108 -0
- package/src/wiki/serve-graph.ts +19 -0
|
@@ -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
|
+
}
|