rvlib-shipkit 0.1.0 → 0.2.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 (53) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +23 -8
  3. package/package.json +29 -13
  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 +161 -0
  11. package/src/assets/kits/identity-kit.ts +80 -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 +122 -0
  16. package/src/assets/server/studio-page.ts +587 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +360 -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_Tui.ts +36 -0
  27. package/src/cli/cmd_Wiki.ts +53 -0
  28. package/src/cli.ts +42 -25
  29. package/src/contracts/run-contracts.ts +33 -0
  30. package/src/core/check-ctx.ts +3 -16
  31. package/src/core/check-suite.ts +16 -2
  32. package/src/core/discover-checks.ts +48 -0
  33. package/src/core/run-check.ts +73 -0
  34. package/src/core/run-ci.ts +54 -0
  35. package/src/core/spawn-timeout.ts +65 -0
  36. package/src/core/task-runner.ts +36 -43
  37. package/src/core/wire.ts +20 -0
  38. package/src/core/write-status.ts +70 -0
  39. package/src/init-repo.ts +60 -0
  40. package/src/repo-config.ts +68 -16
  41. package/src/shipkit.ts +34 -3
  42. package/src/tui/checks-panel.ts +39 -0
  43. package/src/tui/tasks-panel.ts +54 -0
  44. package/src/tui/tui-app.tsx +91 -0
  45. package/src/tui/tui-panel.ts +57 -0
  46. package/src/tui/tui-store.ts +98 -0
  47. package/src/wiki/run-wiki.ts +50 -0
  48. package/src/cli/shipkit-asks.ts +0 -26
  49. package/src/cli/shipkit-check.ts +0 -26
  50. package/src/cli/shipkit-ci.ts +0 -30
  51. package/src/cli/shipkit-contracts.ts +0 -26
  52. package/src/cli/shipkit-init.ts +0 -40
  53. package/src/cli/shipkit-wiki.ts +0 -74
@@ -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,22 @@
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'
14
-
15
- export type RepoCheck = { id: string; logic: (t: CheckCtx) => unknown; remainValidFor?: string | number; version?: number }
19
+ import type { TuiPanelDef } from './tui/tui-panel.ts'
16
20
 
17
21
  export type RepoConfig = {
18
22
  name: string
@@ -26,16 +30,27 @@ export type RepoConfig = {
26
30
  defaults?: Record<string, TaskDef | false>
27
31
  /** repo-specific commit-gate tasks; deps double as race guards */
28
32
  tasks?: TaskDef[]
29
- /** periodic health checks, TTL-cached via the check suite */
30
- checks?: RepoCheck[]
31
- asks?: { dir?: string }
33
+ /** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
34
+ asks?: { dir?: string; emptyText?: string }
32
35
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
36
+ /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
37
+ tui?: { panels?: TuiPanelDef[] }
33
38
  logDir?: string
34
39
  }
35
40
 
41
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'logDir'])
42
+
36
43
  export function defineRepo(c: RepoConfig): RepoConfig {
37
44
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
38
45
  for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
46
+ // unknown keys die loud — a typo'd or removed key silently changing the
47
+ // gate is exactly the never-silence failure mode (A23)
48
+ for (const k of Object.keys(c))
49
+ if (!KNOWN_KEYS.has(k))
50
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
51
+ for (const k of Object.keys(c.defaults ?? {}))
52
+ if (!(FLOOR_IDS as readonly string[]).includes(k))
53
+ throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
39
54
  return c
40
55
  }
41
56
 
@@ -91,7 +106,39 @@ export function contractsTask(c: RepoConfig, root: string): TaskDef {
91
106
 
92
107
  const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
93
108
 
94
- export type FloorResolution = { tasks: TaskDef[]; disabled: string[] }
109
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (rvlib-ts-tools)' | 'built-in (tsc)'
110
+ export type FloorResolution = {
111
+ tasks: TaskDef[]
112
+ /** slots disabled by `defaults: { x: false }` — always printed */
113
+ disabled: string[]
114
+ /** slots nothing could fill — always printed, never silent */
115
+ unfilled: { id: string; reason: string }[]
116
+ /** where each registered slot came from (init prints this) */
117
+ sources: Record<string, FloorSource>
118
+ }
119
+
120
+ // zero-config floor (O6): with no script, imports:check/cycles resolve to
121
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
122
+ // the task, or the reason the slot stays empty.
123
+ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
124
+ if (id === 'imports:check' || id === 'cycles') {
125
+ let cli: string
126
+ try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) }
127
+ catch { return `no '${id}' script and rvlib-ts-tools is unresolvable — reinstall shipkit's deps` }
128
+ return {
129
+ task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
130
+ source: 'built-in (rvlib-ts-tools)',
131
+ }
132
+ }
133
+ if (id === 'typecheck') {
134
+ if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
135
+ const local = join(root, 'node_modules/.bin/tsc')
136
+ const tsc = existsSync(local) ? local : Bun.which('tsc')
137
+ if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
138
+ return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
139
+ }
140
+ return `no package.json '${id}' script`
141
+ }
95
142
 
96
143
  export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
97
144
  const pkgPath = join(root, 'package.json')
@@ -104,16 +151,21 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
104
151
  }
105
152
  const tasks: TaskDef[] = []
106
153
  const disabled: string[] = []
154
+ const unfilled: { id: string; reason: string }[] = []
155
+ const sources: Record<string, FloorSource> = {}
107
156
  for (const id of FLOOR_IDS) {
108
157
  const override = c.defaults?.[id]
109
158
  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] })
159
+ if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
160
+ if (scripts[id] != null) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
161
+ const b = builtinFloorTask(id, root)
162
+ if (typeof b === 'string') unfilled.push({ id, reason: b })
163
+ else { tasks.push(b.task); sources[id] = b.source }
112
164
  }
113
- return { tasks, disabled }
165
+ return { tasks, disabled, unfilled, sources }
114
166
  }
115
167
 
116
- export function allTasks(c: RepoConfig, root: string): { tasks: TaskDef[]; disabled: string[] } {
168
+ export function allTasks(c: RepoConfig, root: string): FloorResolution {
117
169
  const floor = resolveFloor(c, root)
118
- return { tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])], disabled: floor.disabled }
170
+ return { ...floor, tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])] }
119
171
  }
package/src/shipkit.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  // rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
2
2
  // everything else is the machinery behind the shipkit CLI.
3
3
  export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
4
- export type { RepoConfig, RepoCheck } from './repo-config.ts'
4
+ export type { RepoConfig, FloorResolution, FloorSource } from './repo-config.ts'
5
+ export { discoverChecks } from './core/discover-checks.ts'
6
+ export type { DiscoveredCheck } from './core/discover-checks.ts'
5
7
  export { runTasks, selectTasks } from './core/task-runner.ts'
6
8
  export type { TaskDef, TaskResult, RunOpts, RunSummary } from './core/task-runner.ts'
7
9
  export { CheckSuite } from './core/check-suite.ts'
8
- export type { CheckSuiteOpts, GetSecret, SecretSpec } from './core/check-suite.ts'
10
+ export type { CheckSuiteOpts, CheckOutcome, GetSecret, SecretSpec } from './core/check-suite.ts'
11
+ // tui: type-only here — the store/view/panels import ink+mobx, which must
12
+ // not load on every `shipkit ci`; runtime pieces live at rvlib-shipkit/src/tui/*
13
+ export type { TuiPanelDef, TuiRow, TuiRowStatus, MarkedLine } from './tui/tui-panel.ts'
9
14
  export { CheckCtx, RateLimited, ToolUnresolved } from './core/check-ctx.ts'
10
- export { formatLine, parseDuration, fmtAge } from './core/wire.ts'
15
+ export { spawnWithTimeout } from './core/spawn-timeout.ts'
16
+ export type { SpawnOutcome, SpawnTimeoutOpts } from './core/spawn-timeout.ts'
17
+ export { writeStatus } from './core/write-status.ts'
18
+ export type { StatusInput, StatusOutcome } from './core/write-status.ts'
19
+ export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
11
20
  export type { Line } from './core/wire.ts'
12
21
  export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
13
22
  export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
@@ -16,6 +25,28 @@ export type { ContractManifest, ArtifactRule, Detector, ContractEnv } from './co
16
25
  export { allContracts } from './contracts/contract-manifests.ts'
17
26
  export { listAsks, openAsks, checkAsks, createAsk, renderAsksTable } from './asks/asks-queue.ts'
18
27
  export type { Ask, CreateOpts } from './asks/asks-queue.ts'
28
+ // assets surface (folded in from the standalone atelier lib; that npm
29
+ // package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
30
+ // declares its kinds; `shipkit assets …` derives the rest.
31
+ export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
32
+ export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset } from './assets/kit-config.ts'
33
+ export { identityKinds, renderIdentity, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
34
+ export type { Brand } from './assets/kits/identity-kit.ts'
35
+ export { serveStudio } from './assets/server/studio-server.ts'
36
+ export type { Studio, StudioDeps } from './assets/server/studio-server.ts'
37
+ export { page as studioPage } from './assets/server/studio-page.ts'
38
+ export { StudioQueue } from './assets/server/studio-queue.ts'
39
+ export type { Job, QueueState } from './assets/server/studio-queue.ts'
40
+ export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'
41
+ export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
42
+ export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
43
+ export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
44
+ export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
45
+ export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
46
+ export { toPng, alphaHeight } from './assets/image-ops.ts'
47
+ export { updateJson, readJson } from './assets/json-store.ts'
48
+ export { assetDir, listRolls, isRollFile, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
49
+ export type { PickState, PromptOverlay } from './assets/roll-workspace.ts'
19
50
  export { scanMdGraph } from './wiki/scan-md.ts'
20
51
  export { lintMd } from './wiki/lint-md.ts'
21
52
  export { serveMdGraph } from './wiki/serve-graph.ts'
@@ -0,0 +1,39 @@
1
+ // built-in panel: contracts + every discovered *.check.ts through runCheck
2
+ // (both TTL cache layers intact) — per-check results stream in via the
3
+ // onResult tap. status:false: the tasks panel owns STATUS.md in a TUI run.
4
+ import { discoverChecks } from '../core/discover-checks.ts'
5
+ import { runCheck } from '../core/run-check.ts'
6
+ import { formatLine } from '../core/wire.ts'
7
+ import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
8
+
9
+ export function checksPanel(root: string): TuiPanelDef {
10
+ let rows: TuiRow[] = []
11
+ return {
12
+ id: 'checks',
13
+ title: 'Checks',
14
+ rows: () => rows,
15
+ refresh: async (update) => {
16
+ rows = ['contracts', ...discoverChecks(root).map((d) => d.name)]
17
+ .map((n): TuiRow => ({ key: n, label: n, status: 'pending', detail: [] }))
18
+ const byKey = new Map(rows.map((r, i) => [r.key, i]))
19
+ update()
20
+ await runCheck(root, {
21
+ status: false,
22
+ print: () => {},
23
+ onResult: (name, r) => {
24
+ const row: TuiRow = {
25
+ key: name,
26
+ label: name,
27
+ status: r.status,
28
+ ...(r.cached ? { hint: 'cached' } : {}),
29
+ detail: r.lines.map((l) => formatLine(l, false)),
30
+ }
31
+ const i = byKey.get(name)
32
+ if (i == null) { byKey.set(name, rows.length); rows.push(row) } // suite-side names (secret checks) still land
33
+ else rows[i] = row
34
+ update()
35
+ },
36
+ })
37
+ },
38
+ }
39
+ }
@@ -0,0 +1,54 @@
1
+ // built-in panel: the ci gate (floor + contracts + repo tasks) through
2
+ // runCi — results stream in via the onTaskFinish tap. STATUS.md refreshes
3
+ // exactly like a terminal `shipkit ci`; the checks panel writes nothing
4
+ // (one STATUS writer per TUI session).
5
+ import type { RepoConfig } from '../repo-config.ts'
6
+ import { allTasks } from '../repo-config.ts'
7
+ import { runCi } from '../core/run-ci.ts'
8
+ import { formatLine } from '../core/wire.ts'
9
+ import type { TaskResult } from '../core/task-runner.ts'
10
+ import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
11
+
12
+ const taskRow = (r: TaskResult): TuiRow => ({
13
+ key: r.id,
14
+ label: r.id,
15
+ status: r.status === 'pass' ? (r.lines.some((l) => l.kind === 'warn') ? 'warn' : 'pass') : r.status === 'fail' ? 'fail' : 'skip',
16
+ hint: `${(r.durationMs / 1000).toFixed(1)}s`,
17
+ detail: [
18
+ ...r.lines.map((l) => formatLine(l, false)),
19
+ ...(r.status === 'fail' && r.lastLine !== '' ? [`FAIL: ${r.lastLine}`] : []),
20
+ ...(r.note !== '' ? [`skip: ${r.note}`] : []),
21
+ ...(r.logPath != null ? [`full log: ${r.logPath}`] : []),
22
+ ],
23
+ })
24
+
25
+ export function tasksPanel(root: string, config: RepoConfig): TuiPanelDef {
26
+ let rows: TuiRow[] = []
27
+ return {
28
+ id: 'tasks',
29
+ title: 'Tasks',
30
+ rows: () => rows,
31
+ refresh: async (update) => {
32
+ const { tasks, disabled, unfilled } = allTasks(config, root)
33
+ rows = [
34
+ ...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
35
+ ...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: disabled by repo.config defaults'] })),
36
+ ...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
37
+ ]
38
+ const byKey = new Map(rows.map((r, i) => [r.key, i]))
39
+ update()
40
+ await runCi(root, {
41
+ print: () => {}, // rows carry everything the wire lines would
42
+ onTaskFinish: (r) => {
43
+ const i = byKey.get(r.id)
44
+ if (i == null) return
45
+ rows[i] = taskRow(r)
46
+ update()
47
+ },
48
+ })
49
+ // bail/interrupt leftovers never got a finish tap — visible, never a fake pass
50
+ rows = rows.map((r) => (r.status === 'pending' ? { ...r, status: 'skip', detail: ['skip: never ran (bailed or interrupted)'] } : r))
51
+ update()
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,91 @@
1
+ // shipkit tui — the generic view: tab bar, list left, detail right, wire
2
+ // lines normalized to one visual language (✓/✗/⚠/~/»). All data comes from
3
+ // panels (tui-panel.ts); this file owns ONLY rendering + keys.
4
+ import React from 'react'
5
+ import { Box, Text, useApp, useInput } from 'ink'
6
+ import { observer } from 'mobx-react-lite'
7
+ import { markLine, ROW_MARK, type TuiRow } from './tui-panel.ts'
8
+ import type { TuiStore } from './tui-store.ts'
9
+
10
+ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
+
12
+ const RowLine = observer(function RowLine(p: { row: TuiRow; selected: boolean; spin: number; width: number }) {
13
+ const m = ROW_MARK[p.row.status]
14
+ const mark = p.row.status === 'pending' ? SPIN[p.spin % SPIN.length]! : (p.row.mark ?? m.mark)
15
+ return (
16
+ <Text inverse={p.selected} wrap="truncate">
17
+ <Text color={m.color}>{mark.padEnd(2)}</Text>
18
+ <Text dimColor={p.row.status === 'pending'}>{p.row.label.padEnd(p.width)}</Text>
19
+ {p.row.hint != null && <Text dimColor> {p.row.hint}</Text>}
20
+ </Text>
21
+ )
22
+ })
23
+
24
+ const Detail = observer(function Detail(p: { row: TuiRow; spin: number }) {
25
+ const m = ROW_MARK[p.row.status]
26
+ return (
27
+ <Box flexDirection="column" borderStyle="round" borderColor={m.color ?? 'gray'} paddingX={1} flexGrow={1}>
28
+ <Box justifyContent="space-between">
29
+ <Text bold>{p.row.label}</Text>
30
+ {p.row.status === 'pending'
31
+ ? <Text color="cyan">{SPIN[p.spin % SPIN.length]} running</Text>
32
+ : <Text bold color={m.color}>{p.row.status.toUpperCase()}</Text>}
33
+ </Box>
34
+ {p.row.detail.map((l, i) => {
35
+ const c = markLine(l)
36
+ return <Text key={i} bold={c.bold} wrap="truncate-end"><Text color={c.color}>{c.mark}</Text> {c.text}</Text>
37
+ })}
38
+ {p.row.status !== 'pending' && p.row.detail.length === 0 && <Text dimColor>no output</Text>}
39
+ </Box>
40
+ )
41
+ })
42
+
43
+ const Summary = observer(function Summary(p: { store: TuiStore }) {
44
+ const c = p.store.counts
45
+ return (
46
+ <Text>
47
+ <Text color="green">{c.pass} ok</Text><Text dimColor> · </Text>
48
+ <Text color="yellow">{c.warn} warn</Text><Text dimColor> · </Text>
49
+ <Text color="red">{c.fail} fail</Text>
50
+ {c.pending > 0 && <Text color="cyan"> · {SPIN[p.store.spin % SPIN.length]} {c.pending} running</Text>}
51
+ </Text>
52
+ )
53
+ })
54
+
55
+ export const TuiApp = observer(function TuiApp(p: { store: TuiStore }) {
56
+ const app = useApp()
57
+ const s = p.store
58
+ useInput((input, key) => {
59
+ if (input === 'q') app.exit()
60
+ else if (input === 'r') s.refresh()
61
+ else if (/^[1-9]$/.test(input)) s.setTab(Number(input) - 1)
62
+ else if (key.tab || key.rightArrow) s.cycleTab(1)
63
+ else if (key.leftArrow) s.cycleTab(-1)
64
+ else if (key.downArrow || input === 'j') s.move(1)
65
+ else if (key.upArrow || input === 'k') s.move(-1)
66
+ })
67
+ const rows = s.activeRows
68
+ const width = Math.max(8, ...rows.map((r) => r.label.length))
69
+ return (
70
+ <Box flexDirection="column">
71
+ <Box justifyContent="space-between">
72
+ <Text bold color="cyan">⚙ {s.name} — shipkit</Text>
73
+ <Summary store={s} />
74
+ </Box>
75
+ <Box gap={2}>
76
+ {s.panels.map((panel, i) => (
77
+ <Text key={panel.id} bold inverse={i === s.tab}> {i + 1} {panel.title}{s.rowsOf(panel).length > 0 ? ` (${s.rowsOf(panel).length})` : ''} </Text>
78
+ ))}
79
+ {s.error != null && <Text color="red">{s.error}</Text>}
80
+ </Box>
81
+ <Box gap={1}>
82
+ <Box flexDirection="column" flexShrink={0}>
83
+ {rows.length === 0 && <Text color="green">{s.activePanel.emptyText ?? '(empty)'}</Text>}
84
+ {rows.map((r, i) => <RowLine key={r.key} row={r} selected={i === s.sel} spin={s.spin} width={width} />)}
85
+ </Box>
86
+ {s.selectedRow != null && <Detail row={s.selectedRow} spin={s.spin} />}
87
+ </Box>
88
+ <Text dimColor>1-{s.panels.length}/tab switch · j/k move · r refresh · q quit</Text>
89
+ </Box>
90
+ )
91
+ })