rvlib-shipkit 0.1.1 → 0.3.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +23 -8
  3. package/package.json +15 -3
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +179 -0
  11. package/src/assets/kits/identity-kit.ts +119 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +142 -0
  16. package/src/assets/server/studio-page.ts +620 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +412 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Serve.ts +34 -0
  27. package/src/cli/cmd_Tui.ts +36 -0
  28. package/src/cli/cmd_Wiki.ts +53 -0
  29. package/src/cli.ts +44 -25
  30. package/src/contracts/run-contracts.ts +33 -0
  31. package/src/core/check-ctx.ts +3 -16
  32. package/src/core/check-suite.ts +16 -2
  33. package/src/core/discover-checks.ts +48 -0
  34. package/src/core/run-check.ts +73 -0
  35. package/src/core/run-ci.ts +54 -0
  36. package/src/core/spawn-timeout.ts +65 -0
  37. package/src/core/task-runner.ts +36 -43
  38. package/src/core/wire.ts +20 -0
  39. package/src/core/write-status.ts +70 -0
  40. package/src/init-repo.ts +60 -0
  41. package/src/repo-config.ts +82 -15
  42. package/src/serve/hub-pages.ts +84 -0
  43. package/src/serve/run-serve.ts +85 -0
  44. package/src/serve/serve-hub.ts +77 -0
  45. package/src/shipkit.ts +39 -3
  46. package/src/tui/checks-panel.ts +39 -0
  47. package/src/tui/tasks-panel.ts +54 -0
  48. package/src/tui/tui-app.tsx +91 -0
  49. package/src/tui/tui-panel.ts +57 -0
  50. package/src/tui/tui-store.ts +98 -0
  51. package/src/wiki/run-wiki.ts +50 -0
  52. package/src/cli/shipkit-asks.ts +0 -26
  53. package/src/cli/shipkit-check.ts +0 -26
  54. package/src/cli/shipkit-ci.ts +0 -30
  55. package/src/cli/shipkit-contracts.ts +0 -26
  56. package/src/cli/shipkit-init.ts +0 -41
  57. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,73 @@
1
+ // periodic health — contracts + every *.check.ts in the repo. Discovery IS
2
+ // the registration (core/discover-checks.ts); each check runs as a
3
+ // subprocess: wire lines parsed back, exit code is truth. A check that runs
4
+ // its own CheckSuite keeps its own TTL cache and is never double-cached
5
+ // here; plain checks get a short orchestrator TTL. `--fresh` busts both
6
+ // layers (SHIPKIT_FRESH=1 + RV_CHECK_FRESH=1 travel to children).
7
+ import { join, relative } from 'path'
8
+ import { CheckSuite, type CheckOutcome } from './check-suite.ts'
9
+ import { discoverChecks } from './discover-checks.ts'
10
+ import { spawnWithTimeout } from './spawn-timeout.ts'
11
+ import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
12
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
13
+ import { formatLine, parseWireOutput } from './wire.ts'
14
+ import { writeStatus } from './write-status.ts'
15
+
16
+ // orchestrator cache for checks WITHOUT their own suite — short on purpose:
17
+ // this layer only absorbs back-to-back runs, real TTLs live in the checks
18
+ const DISCOVERED_TTL = '30m'
19
+ const CHECK_TIMEOUT_MS = 120_000
20
+
21
+ export type CheckOpts = {
22
+ json?: boolean
23
+ fresh?: boolean
24
+ /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
25
+ status?: boolean
26
+ /** line sink — commands pass their context stream; defaults to console */
27
+ print?: (line: string) => void
28
+ /** per-check completion tap — the TUI checks panel streams results in */
29
+ onResult?: (name: string, r: CheckOutcome) => void
30
+ }
31
+
32
+ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
33
+ const print = o.print ?? ((l: string) => console.log(l))
34
+ const c = await loadRepoConfig(root)
35
+ if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
36
+ const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
37
+ suite.addCheck({
38
+ name: 'contracts',
39
+ logic: async (t) => {
40
+ applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
41
+ },
42
+ })
43
+ const childEnv: Record<string, string | undefined> = {
44
+ ...process.env,
45
+ ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
46
+ }
47
+ for (const d of discoverChecks(root)) {
48
+ const rel = relative(root, d.path)
49
+ suite.addCheck({
50
+ name: d.name,
51
+ remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
52
+ logic: async (t) => {
53
+ const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
54
+ for (const l of parseWireOutput(r.out)) t.lines.push(l)
55
+ if (t.lines.some((l) => l.kind === 'warn')) t.warned = true
56
+ if (t.lines.some((l) => l.kind === 'fail')) t.failed = true
57
+ // exit code is truth — a non-zero exit with no FAIL line
58
+ // (crash, non-wire output, timeout) must still fail loud
59
+ if (r.code !== 0 && !t.failed)
60
+ t.fail(`${d.name} exited ${r.code}${r.timedOut ? ` (timeout after ${CHECK_TIMEOUT_MS}ms)` : ''} — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`, `bun ${rel}`)
61
+ },
62
+ })
63
+ }
64
+ const r = await suite.execute()
65
+ const statusOutcome = o.status !== false
66
+ ? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
67
+ : null
68
+ if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
69
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
70
+ for (const l of r.lines) print(formatLine(l, color))
71
+ if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
72
+ return r.exitCode
73
+ }
@@ -0,0 +1,54 @@
1
+ // the ci gate — floor + contracts + repo tasks through the DAG runner.
2
+ // Plain module behind cmd_Ci (logic stays out of command classes) so tests
3
+ // can run the gate in-process. Returns the exit code; json prints the
4
+ // structured summary. Every run refreshes STATUS.md (write-status.ts —
5
+ // atomic + lock) unless status:false.
6
+ import { join } from 'path'
7
+ import { allTasks, loadRepoConfig } from '../repo-config.ts'
8
+ import { runTasks, type TaskResult } from './task-runner.ts'
9
+ import { writeStatus } from './write-status.ts'
10
+
11
+ export type CiOpts = {
12
+ bail?: boolean
13
+ json?: boolean
14
+ skipGroups?: string[]
15
+ withIds?: string[]
16
+ onlyIds?: string[]
17
+ /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
18
+ status?: boolean
19
+ /** line sink — commands pass their context stream; defaults to console */
20
+ print?: (line: string) => void
21
+ /** per-task completion tap — the TUI tasks panel streams results in */
22
+ onTaskFinish?: (r: TaskResult) => void
23
+ }
24
+
25
+ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
26
+ const print = o.print ?? ((l: string) => console.log(l))
27
+ const collected: string[] = []
28
+ const sink = (l: string): void => { collected.push(l); print(l) }
29
+ const c = await loadRepoConfig(root)
30
+ const { tasks, disabled, unfilled } = allTasks(c, root)
31
+ for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
32
+ // a floor slot nothing could fill is VISIBLE — a thin gate must never
33
+ // look like a full one (the fresh-repo floor gap)
34
+ for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
35
+ const summary = await runTasks({
36
+ tasks,
37
+ logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
38
+ cwd: root,
39
+ bail: o.bail ?? false,
40
+ skipGroups: o.skipGroups ?? [],
41
+ withIds: o.withIds ?? [],
42
+ onlyIds: o.onlyIds ?? [],
43
+ print: sink,
44
+ onTaskFinish: o.onTaskFinish,
45
+ })
46
+ const code = summary.ok ? 0 : 1
47
+ if (o.status !== false) {
48
+ const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
49
+ if (!st.written) print(`WARN ${st.warn}`)
50
+ }
51
+ if (o.json === true)
52
+ print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
53
+ return code
54
+ }
@@ -0,0 +1,65 @@
1
+ // THE spawn-with-timeout — one code path for every shipkit surface that
2
+ // runs a child process (task runner, CheckCtx.exec, *.check.ts discovery).
3
+ // Resolves the binary first (Bun.spawn throws on a missing one), streams
4
+ // stdout+stderr, SIGTERM at timeoutMs, SIGKILL after a grace period so a
5
+ // SIGTERM-ignoring child can never hang the caller.
6
+
7
+ import { existsSync } from 'fs'
8
+
9
+ export type SpawnOutcome = {
10
+ code: number
11
+ out: string
12
+ timedOut: boolean
13
+ /** true when the child never ran (empty cmd, binary missing, spawn threw) — `out` carries the reason */
14
+ spawnFailed: boolean
15
+ }
16
+
17
+ export type SpawnTimeoutOpts = {
18
+ timeoutMs: number
19
+ /** ms between SIGTERM and SIGKILL (default 2000) */
20
+ killGraceMs?: number
21
+ cwd?: string
22
+ env?: Record<string, string | undefined>
23
+ /** raw chunk tap (log streaming, last-line tracking) — `out` accumulates regardless */
24
+ onChunk?: (text: string) => void
25
+ /** handle tap so callers can kill externally (bail / SIGINT) */
26
+ onSpawn?: (proc: ReturnType<typeof Bun.spawn>) => void
27
+ }
28
+
29
+ export async function spawnWithTimeout(cmd: string[], opts: SpawnTimeoutOpts): Promise<SpawnOutcome> {
30
+ const head = cmd[0]
31
+ if (head == null) return { code: -1, out: 'empty command', timedOut: false, spawnFailed: true }
32
+ // path-style heads verified too — Bun.spawn on a missing path throws,
33
+ // which must surface as a result, never a crash
34
+ const bin = head.includes('/') ? head : Bun.which(head)
35
+ if (bin == null || !existsSync(bin)) return { code: -1, out: `${head}: not found`, timedOut: false, spawnFailed: true }
36
+ let proc: ReturnType<typeof Bun.spawn>
37
+ try {
38
+ proc = Bun.spawn([bin, ...cmd.slice(1)], {
39
+ stdout: 'pipe',
40
+ stderr: 'pipe',
41
+ cwd: opts.cwd,
42
+ env: opts.env as Record<string, string> | undefined,
43
+ })
44
+ } catch (e) {
45
+ return { code: -1, out: `spawn error: ${String(e).slice(0, 200)}`, timedOut: false, spawnFailed: true }
46
+ }
47
+ opts.onSpawn?.(proc)
48
+ let timedOut = false
49
+ const timer = setTimeout(() => { timedOut = true; try { proc.kill() } catch {} }, opts.timeoutMs)
50
+ const killer = setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, opts.timeoutMs + (opts.killGraceMs ?? 2000))
51
+ let out = ''
52
+ const eat = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
53
+ const dec = new TextDecoder()
54
+ for await (const chunk of stream) {
55
+ const text = dec.decode(chunk)
56
+ out += text
57
+ opts.onChunk?.(text)
58
+ }
59
+ }
60
+ await Promise.all([eat(proc.stdout as ReadableStream<Uint8Array>), eat(proc.stderr as ReadableStream<Uint8Array>)])
61
+ const code = await proc.exited
62
+ clearTimeout(timer)
63
+ clearTimeout(killer)
64
+ return { code, out, timedOut, spawnFailed: false }
65
+ }
@@ -13,9 +13,10 @@
13
13
  // - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
14
14
  // optIn/skipped-group drops that dep (filtered = satisfied) — explicit
15
15
  // filters win over guards.
16
- import { createWriteStream, existsSync, mkdirSync, rmSync } from 'fs'
16
+ import { createWriteStream, mkdirSync, rmSync, type WriteStream } from 'fs'
17
17
  import { join } from 'path'
18
18
  import { CheckCtx } from './check-ctx.ts'
19
+ import { spawnWithTimeout } from './spawn-timeout.ts'
19
20
  import { formatLine, type Line } from './wire.ts'
20
21
 
21
22
  export type TaskDef = {
@@ -54,6 +55,10 @@ export type RunOpts = {
54
55
  /** print sink — tests capture, production defaults to console.log */
55
56
  print?: (line: string) => void
56
57
  color?: boolean
58
+ /** per-task completion tap (the TUI streams results in as they land) —
59
+ * fires for pass/fail/dep-skip; bail/interrupt leftovers never finish,
60
+ * callers sweep their own pending state after runTasks resolves */
61
+ onTaskFinish?: (r: TaskResult) => void
57
62
  }
58
63
 
59
64
  export type RunSummary = { ok: boolean; results: TaskResult[]; interrupted: boolean }
@@ -158,6 +163,7 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
158
163
  t.exitCode = code
159
164
  t.durationMs = t.startedAt > 0 ? Date.now() - t.startedAt : 0
160
165
  done.set(t.id, t)
166
+ o.onTaskFinish?.({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines })
161
167
  const dur = ` (${(t.durationMs / 1000).toFixed(1)}s)`
162
168
  if (status === 'pass') {
163
169
  // WARNs nag and skips stay visible (exempt contracts) even on pass
@@ -189,49 +195,36 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
189
195
  return
190
196
  }
191
197
  const cmd = t.cmd!
192
- const head = cmd[0]
193
- const bin = head == null ? null : head.includes('/') ? head : Bun.which(head)
194
- // path-style heads verified too — Bun.spawn on a missing path
195
- // throws, which must surface as a wire FAIL, never a crash
196
- if (bin == null || !existsSync(bin)) { t.lastLine = `${head}: not found`; finish(t, 'fail', -1); return }
197
- try {
198
- const out = createWriteStream(t.logPath!, { flags: 'a' })
199
- const proc = Bun.spawn([bin, ...cmd.slice(1)], {
200
- stdout: 'pipe', stderr: 'pipe',
201
- env: { ...process.env, ...t.env },
202
- cwd: t.cwd ?? o.cwd,
203
- })
204
- procs.set(t.id, proc)
205
- let timedOut = false
206
- const ms = t.timeoutMs ?? 600_000
207
- const timer = setTimeout(() => { timedOut = true; try { proc.kill() } catch {} }, ms)
208
- const killer = setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, ms + 2000)
209
- const eat = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
210
- const dec = new TextDecoder()
211
- for await (const chunk of stream) {
212
- const text = dec.decode(chunk)
213
- out.write(text)
214
- for (const raw of text.split(/\r?\n/)) {
215
- const s = stripAnsi(raw).trim()
216
- if (s !== '') t.lastLine = s.slice(0, 200)
217
- }
198
+ const ms = t.timeoutMs ?? 600_000
199
+ // log stream opens only once the child actually spawned — a
200
+ // missing binary must not leave a dangling open on the log dir
201
+ let out: WriteStream | null = null
202
+ const r = await spawnWithTimeout(cmd, {
203
+ timeoutMs: ms,
204
+ env: { ...process.env, ...t.env },
205
+ cwd: t.cwd ?? o.cwd,
206
+ onSpawn: (proc) => {
207
+ out = createWriteStream(t.logPath!, { flags: 'a' })
208
+ procs.set(t.id, proc)
209
+ },
210
+ onChunk: (text) => {
211
+ out?.write(text)
212
+ for (const raw of text.split(/\r?\n/)) {
213
+ const s = stripAnsi(raw).trim()
214
+ if (s !== '') t.lastLine = s.slice(0, 200)
218
215
  }
219
- }
220
- await Promise.all([eat(proc.stdout), eat(proc.stderr)])
221
- const code = await proc.exited
222
- clearTimeout(timer)
223
- clearTimeout(killer)
224
- procs.delete(t.id)
225
- out.end()
226
- const killReason = killed.get(t.id)
227
- if (killReason != null) { t.note = `killed (${killReason})`; finish(t, 'skipped', code); return }
228
- if (timedOut) t.lastLine = `timeout after ${ms}ms — ${t.lastLine}`
229
- finish(t, code === 0 ? 'pass' : 'fail', code)
230
- } catch (e) {
231
- procs.delete(t.id)
232
- t.lastLine = `spawn error: ${String(e).slice(0, 200)}`
233
- finish(t, 'fail', -1)
234
- }
216
+ },
217
+ })
218
+ procs.delete(t.id)
219
+ // TS narrows `out` to its initializer across the await (assignment
220
+ // happens in the onSpawn closure) — widen explicitly
221
+ ;(out as WriteStream | null)?.end()
222
+ // missing binary / spawn throw surface as a wire FAIL, never a crash
223
+ if (r.spawnFailed) { t.lastLine = r.out; finish(t, 'fail', -1); return }
224
+ const killReason = killed.get(t.id)
225
+ if (killReason != null) { t.note = `killed (${killReason})`; finish(t, 'skipped', r.code); return }
226
+ if (r.timedOut) t.lastLine = `timeout after ${ms}ms — ${t.lastLine}`
227
+ finish(t, r.code === 0 ? 'pass' : 'fail', r.code)
235
228
  })().finally(() => running.delete(t.id))
236
229
  running.set(t.id, p)
237
230
  }
package/src/core/wire.ts CHANGED
@@ -22,6 +22,26 @@ export function formatLine(l: Line, color: boolean): string {
22
22
  }
23
23
  }
24
24
 
25
+ // inverse of formatLine, for subprocess checks: parse a child's wire output
26
+ // back into Lines. ANSI is stripped, blank lines dropped, unknown lines kept
27
+ // as notes (children may print context); an indented `fix:` binds to the
28
+ // preceding WARN/FAIL severity.
29
+ export function parseWireOutput(text: string): Line[] {
30
+ const lines: Line[] = []
31
+ let lastSeverity: 'fix-warn' | 'fix-fail' = 'fix-fail'
32
+ for (const raw of text.split('\n')) {
33
+ const l = raw.replace(/\x1b\[[0-9;]*m/g, '').trimEnd()
34
+ if (l.trim() === '') continue
35
+ if (l.startsWith('ok: ')) lines.push({ kind: 'ok', text: l.slice(4) })
36
+ else if (l.startsWith('WARN ')) { lastSeverity = 'fix-warn'; lines.push({ kind: 'warn', text: l.slice(5) }) }
37
+ else if (l.startsWith('FAIL: ')) { lastSeverity = 'fix-fail'; lines.push({ kind: 'fail', text: l.slice(6) }) }
38
+ else if (l.startsWith('skip: ')) lines.push({ kind: 'skip', text: l.slice(6) })
39
+ else if (/^\s+fix: /.test(l)) lines.push({ kind: lastSeverity, text: l.trim().slice(5) })
40
+ else lines.push({ kind: 'note', text: l })
41
+ }
42
+ return lines
43
+ }
44
+
25
45
  export function parseDuration(d: string | number): number {
26
46
  if (typeof d === 'number') return d
27
47
  const m = d.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/)
@@ -0,0 +1,70 @@
1
+ // THE STATUS.md writer — every repo gets one (O4, one-runner GO
2
+ // 2026-07-22): timestamp header + verdict + fenced compact render, the
3
+ // shape rv-doctor pioneered. Atomic (tmp file + rename) and lock-guarded:
4
+ // concurrent runners never interleave — the loser skips with a WARN
5
+ // outcome, never corrupts the file.
6
+
7
+ import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
8
+ import { join } from 'path'
9
+
10
+ export type StatusInput = {
11
+ /** repo name — headline */
12
+ name: string
13
+ /** the command that produced this render, e.g. 'shipkit ci' */
14
+ command: string
15
+ /** the run's printed lines (ANSI stripped here) */
16
+ lines: string[]
17
+ exitCode: number
18
+ }
19
+
20
+ export type StatusOutcome = { written: boolean; warn?: string }
21
+
22
+ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
23
+ // a runner that died without unlocking must not wedge STATUS.md forever
24
+ const LOCK_STALE_MS = 120_000
25
+
26
+ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
27
+ const lockPath = join(root, 'STATUS.md.lock')
28
+ const grab = (): boolean => {
29
+ try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
30
+ }
31
+ if (!grab()) {
32
+ let stale = false
33
+ try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
34
+ // lock vanished between grab and stat — the racing writer just
35
+ // finished; still skip this round rather than fight over it
36
+ }
37
+ if (stale) rmSync(lockPath, { force: true })
38
+ if (!stale || !grab())
39
+ return { written: false, warn: `STATUS.md locked by another runner (${lockPath}) — skipped this write` }
40
+ }
41
+ try {
42
+ const clean = s.lines.map(stripAnsi)
43
+ const fails = clean.filter((l) => l.startsWith('FAIL')).length
44
+ const warns = clean.filter((l) => l.startsWith('WARN')).length
45
+ const verdict = s.exitCode !== 0 || fails > 0
46
+ ? `RED — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
47
+ : warns > 0
48
+ ? `GREEN with nags — ${warns} WARN. Proceed.`
49
+ : 'GREEN — clear to ship.'
50
+ // local time, not UTC — an evening run must not stamp yesterday's date
51
+ const now = new Date()
52
+ const p = (n: number): string => String(n).padStart(2, '0')
53
+ const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`
54
+ const body = `<!-- generated by \`${s.command}\` — commit me to track progress over time -->
55
+ # ${s.name} status — ${stamp}
56
+
57
+ **${verdict}**
58
+
59
+ \`\`\`
60
+ ${clean.join('\n').trim()}
61
+ \`\`\`
62
+ `
63
+ const tmpPath = join(root, `.STATUS.md.tmp-${process.pid}`)
64
+ writeFileSync(tmpPath, body)
65
+ renameSync(tmpPath, join(root, 'STATUS.md'))
66
+ return { written: true }
67
+ } finally {
68
+ rmSync(lockPath, { force: true })
69
+ }
70
+ }
@@ -0,0 +1,60 @@
1
+ // scaffold repo.config.ts + package.json scripts, then print how the floor
2
+ // resolved (zero-config O6: imports/cycles/typecheck fill from shipkit's
3
+ // deps — every gap is printed, never silent). Plain module behind cmd_Init.
4
+ // Refuses to overwrite an existing config; script merge never clobbers
5
+ // existing keys. `bin` switches script bodies from the npm bin to an
6
+ // in-repo-source CLI path (`bun <path> ci`) for repos that consume shipkit
7
+ // by path instead of from node_modules.
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { defineRepo, resolveFloor } from './repo-config.ts'
11
+
12
+ const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
13
+
14
+ export default defineRepo({
15
+ name: '${name}',
16
+ venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
17
+ social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
18
+ tasks: [], // repo commit-gate tasks; deps double as race guards
19
+ // health checks: no config — drop a <name>.check.ts file anywhere,
20
+ // \`shipkit check\` auto-discovers and runs it (wire output, exit code)
21
+ })
22
+ `
23
+
24
+ export type InitOpts = {
25
+ /** in-repo-source consumers: path to shipkit's cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>` */
26
+ bin?: string
27
+ }
28
+
29
+ export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
30
+ const configPath = join(root, 'repo.config.ts')
31
+ if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
32
+ const pkgPath = join(root, 'package.json')
33
+ if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
34
+ const pkgRaw = readFileSync(pkgPath, 'utf8')
35
+ const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
36
+ const name = pkg.name ?? 'my-repo'
37
+ writeFileSync(configPath, CONFIG_TEMPLATE(name))
38
+ console.log('ok: repo.config.ts created')
39
+ const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
40
+ pkg.scripts ??= {}
41
+ const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
42
+ let added = 0
43
+ for (const k of ['ci', 'check', 'contracts'] as const) {
44
+ const v = run(k)
45
+ if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
46
+ else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
47
+ }
48
+ if (added > 0) {
49
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
50
+ console.log(`ok: ${added} package.json script(s) added`)
51
+ }
52
+ // show what the gate actually is — a thin floor must be seen at init
53
+ // time, not discovered after a bad commit slipped through
54
+ const floor = resolveFloor(defineRepo({ name }), root)
55
+ for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
56
+ for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
57
+ console.log('next: fill venues/social in repo.config.ts, then `bun run ci`')
58
+ console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
59
+ return 0
60
+ }
@@ -1,18 +1,35 @@
1
1
  // defineRepo — the ONE import a repo needs: repo.config.ts at the root
2
- // declares venues/social/tasks/checks, and the toolkit derives everything
3
- // else. The FLOOR (lint/typecheck/test/imports:check/cycles) auto-registers
4
- // iff the matching package.json script exists; disabling via
5
- // `defaults: { x: false }` always prints a skipped-by-config line, never
6
- // silence. The `contracts` task is always present when any contract applies.
2
+ // declares venues/social/tasks, and the toolkit derives everything else.
3
+ // Health checks are NOT declared here: drop a `<name>.check.ts` file and
4
+ // `shipkit check` discovers it (core/discover-checks.ts). The FLOOR
5
+ // (lint/typecheck/test/imports:check/cycles): a package.json script wins;
6
+ // with no script, imports:check/cycles fill from shipkit's own
7
+ // rvlib-ts-tools dep and typecheck from the repo's tsc (O6 zero-config);
8
+ // a slot that can't fill emits a visible reason, disabling via
9
+ // `defaults: { x: false }` prints a skipped-by-config line — never silence.
10
+ // The `contracts` task is always present when any contract applies.
7
11
  import { existsSync, readFileSync } from 'fs'
8
12
  import { join } from 'path'
13
+ import { fileURLToPath } from 'url'
9
14
  import { allContracts } from './contracts/contract-manifests.ts'
10
15
  import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts/contracts-engine.ts'
11
16
  import type { ContractManifest } from './contracts/contract-types.ts'
12
17
  import type { TaskDef } from './core/task-runner.ts'
13
18
  import type { CheckCtx } from './core/check-ctx.ts'
19
+ import type { TuiPanelDef } from './tui/tui-panel.ts'
20
+ import type { MdGraph } from './wiki/md-graph.ts'
14
21
 
15
- export type RepoCheck = { id: string; logic: (t: CheckCtx) => unknown; remainValidFor?: string | number; version?: number }
22
+ // `shipkit serve` — the ONE hub server per repo (run-serve.ts resolves the
23
+ // rest by convention: agent/ graph when the dir exists, studio when the
24
+ // kit loads)
25
+ export type ServeConfig = {
26
+ /** hub port — house range 4800+; default hashes the repo name into 4800-4979 */
27
+ port?: number
28
+ /** custom wiki-graph provider for repos with their own link semantics; default: generic md scan of the repo root */
29
+ wikiGraph?: { title?: string; load: () => MdGraph }
30
+ /** agent/ graph entrypoint (default: index.md AND README.md) */
31
+ agentIndex?: string
32
+ }
16
33
 
17
34
  export type RepoConfig = {
18
35
  name: string
@@ -26,16 +43,29 @@ export type RepoConfig = {
26
43
  defaults?: Record<string, TaskDef | false>
27
44
  /** repo-specific commit-gate tasks; deps double as race guards */
28
45
  tasks?: TaskDef[]
29
- /** periodic health checks, TTL-cached via the check suite */
30
- checks?: RepoCheck[]
31
- asks?: { dir?: string }
46
+ /** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
47
+ asks?: { dir?: string; emptyText?: string }
32
48
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
49
+ /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
50
+ tui?: { panels?: TuiPanelDef[] }
51
+ /** the `shipkit serve` hub: port + optional graph hooks */
52
+ serve?: ServeConfig
33
53
  logDir?: string
34
54
  }
35
55
 
56
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'serve', 'logDir'])
57
+
36
58
  export function defineRepo(c: RepoConfig): RepoConfig {
37
59
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
38
60
  for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
61
+ // unknown keys die loud — a typo'd or removed key silently changing the
62
+ // gate is exactly the never-silence failure mode (A23)
63
+ for (const k of Object.keys(c))
64
+ if (!KNOWN_KEYS.has(k))
65
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
66
+ for (const k of Object.keys(c.defaults ?? {}))
67
+ if (!(FLOOR_IDS as readonly string[]).includes(k))
68
+ throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
39
69
  return c
40
70
  }
41
71
 
@@ -91,7 +121,39 @@ export function contractsTask(c: RepoConfig, root: string): TaskDef {
91
121
 
92
122
  const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
93
123
 
94
- export type FloorResolution = { tasks: TaskDef[]; disabled: string[] }
124
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (rvlib-ts-tools)' | 'built-in (tsc)'
125
+ export type FloorResolution = {
126
+ tasks: TaskDef[]
127
+ /** slots disabled by `defaults: { x: false }` — always printed */
128
+ disabled: string[]
129
+ /** slots nothing could fill — always printed, never silent */
130
+ unfilled: { id: string; reason: string }[]
131
+ /** where each registered slot came from (init prints this) */
132
+ sources: Record<string, FloorSource>
133
+ }
134
+
135
+ // zero-config floor (O6): with no script, imports:check/cycles resolve to
136
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
137
+ // the task, or the reason the slot stays empty.
138
+ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
139
+ if (id === 'imports:check' || id === 'cycles') {
140
+ let cli: string
141
+ try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) }
142
+ catch { return `no '${id}' script and rvlib-ts-tools is unresolvable — reinstall shipkit's deps` }
143
+ return {
144
+ task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
145
+ source: 'built-in (rvlib-ts-tools)',
146
+ }
147
+ }
148
+ if (id === 'typecheck') {
149
+ if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
150
+ const local = join(root, 'node_modules/.bin/tsc')
151
+ const tsc = existsSync(local) ? local : Bun.which('tsc')
152
+ if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
153
+ return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
154
+ }
155
+ return `no package.json '${id}' script`
156
+ }
95
157
 
96
158
  export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
97
159
  const pkgPath = join(root, 'package.json')
@@ -104,16 +166,21 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
104
166
  }
105
167
  const tasks: TaskDef[] = []
106
168
  const disabled: string[] = []
169
+ const unfilled: { id: string; reason: string }[] = []
170
+ const sources: Record<string, FloorSource> = {}
107
171
  for (const id of FLOOR_IDS) {
108
172
  const override = c.defaults?.[id]
109
173
  if (override === false) { disabled.push(id); continue }
110
- if (override != null) { tasks.push(override); continue }
111
- if (scripts[id] != null) tasks.push({ id, cmd: ['bun', 'run', id] })
174
+ if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
175
+ if (scripts[id] != null) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
176
+ const b = builtinFloorTask(id, root)
177
+ if (typeof b === 'string') unfilled.push({ id, reason: b })
178
+ else { tasks.push(b.task); sources[id] = b.source }
112
179
  }
113
- return { tasks, disabled }
180
+ return { tasks, disabled, unfilled, sources }
114
181
  }
115
182
 
116
- export function allTasks(c: RepoConfig, root: string): { tasks: TaskDef[]; disabled: string[] } {
183
+ export function allTasks(c: RepoConfig, root: string): FloorResolution {
117
184
  const floor = resolveFloor(c, root)
118
- return { tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])], disabled: floor.disabled }
185
+ return { ...floor, tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])] }
119
186
  }