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
@@ -0,0 +1,41 @@
1
+ // shipkit ci — the commit/deploy gate. Thin clipanion surface; logic in
2
+ // core/run-ci.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runCi } from '../core/run-ci.ts'
5
+
6
+ export class CiCommand extends Command {
7
+ static override paths = [['ci']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Gate',
11
+ description: 'commit gate: floor + contracts + repo tasks through the DAG runner',
12
+ examples: [
13
+ ['full gate', 'shipkit ci'],
14
+ ['stop at the first failure', 'shipkit ci --bail'],
15
+ ['one task + its dep closure', 'shipkit ci --only typecheck'],
16
+ ['include an opt-in task', 'shipkit ci --with up:itch'],
17
+ ],
18
+ })
19
+
20
+ bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
21
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
22
+ skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
23
+ withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
24
+ only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
25
+
26
+ async execute(): Promise<number> {
27
+ try {
28
+ return await runCi(process.cwd(), {
29
+ bail: this.bail,
30
+ json: this.json,
31
+ skipGroups: this.skip,
32
+ withIds: this.withIds,
33
+ onlyIds: this.only,
34
+ print: (l) => this.context.stdout.write(`${l}\n`),
35
+ })
36
+ } catch (e) {
37
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
38
+ return 1
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ // shipkit contracts — venue fulfillness alone. Thin clipanion surface;
2
+ // logic in contracts/run-contracts.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runContracts } from '../contracts/run-contracts.ts'
5
+
6
+ export class ContractsCommand extends Command {
7
+ static override paths = [['contracts']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Gate',
11
+ description: 'release-contract fulfillness, fast and filesystem-only — deploy tasks dep on it',
12
+ examples: [
13
+ ['all applicable contracts', 'shipkit contracts'],
14
+ ['one venue', 'shipkit contracts --venue itch'],
15
+ ['machine-readable', 'shipkit contracts --json'],
16
+ ],
17
+ })
18
+
19
+ venue = Option.String('--venue', { required: false, description: 'check one venue only (itch, steam, …)' })
20
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
21
+
22
+ async execute(): Promise<number> {
23
+ try {
24
+ return await runContracts(process.cwd(), {
25
+ venue: this.venue,
26
+ json: this.json,
27
+ print: (l) => this.context.stdout.write(`${l}\n`),
28
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
29
+ })
30
+ } catch (e) {
31
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
32
+ return 1
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,28 @@
1
+ // shipkit init — scaffold repo.config.ts + package.json scripts. Thin
2
+ // clipanion surface; logic in init-repo.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { initRepo } from '../init-repo.ts'
5
+
6
+ export class InitCommand extends Command {
7
+ static override paths = [['init']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Setup',
11
+ description: 'scaffold repo.config.ts + ci/check/contracts package.json scripts, then print how the floor resolved',
12
+ examples: [
13
+ ['bootstrap the current repo', 'shipkit init'],
14
+ ['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
15
+ ],
16
+ })
17
+
18
+ bin = Option.String('--bin', { description: 'path to shipkit cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>`' })
19
+
20
+ async execute(): Promise<number> {
21
+ try {
22
+ return await initRepo(process.cwd(), { bin: this.bin })
23
+ } catch (e) {
24
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
25
+ return 1
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,36 @@
1
+ // shipkit tui — the live dashboard: Tasks (the ci gate, streamed through
2
+ // the DAG runner) + Checks (contracts + discovered *.check.ts) built in,
3
+ // repo extras injected via repo.config `tui: { panels: [...] }`.
4
+ import React from 'react'
5
+ import { render } from 'ink'
6
+ import { configure } from 'mobx'
7
+ import { Command } from 'clipanion'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+ import { checksPanel } from '../tui/checks-panel.ts'
10
+ import { tasksPanel } from '../tui/tasks-panel.ts'
11
+ import { TuiApp } from '../tui/tui-app.tsx'
12
+ import { TuiStore } from '../tui/tui-store.ts'
13
+
14
+ export class TuiCommand extends Command {
15
+ static override paths = [['tui']]
16
+
17
+ static override usage = Command.Usage({
18
+ category: 'Health',
19
+ description: 'live dashboard: ci tasks + discovered checks, plus panels the repo injects via repo.config tui.panels',
20
+ examples: [['open the dashboard', 'shipkit tui']],
21
+ })
22
+
23
+ async execute(): Promise<number> {
24
+ const root = process.cwd()
25
+ const c = await loadRepoConfig(root)
26
+ // playbook (build/mobx): enforceActions warns on stray mutations — the
27
+ // only signal. Read-warning flags stay off: useInput handler reads are
28
+ // legitimately untracked and would false-positive.
29
+ configure({ enforceActions: 'always' })
30
+ const store = new TuiStore(c.name, [tasksPanel(root, c), checksPanel(root), ...(c.tui?.panels ?? [])])
31
+ store.refresh() // instant static paint; results stream in
32
+ const app = render(React.createElement(TuiApp, { store }))
33
+ await app.waitUntilExit()
34
+ return 0
35
+ }
36
+ }
@@ -0,0 +1,53 @@
1
+ // shipkit wiki — md link-graph server + orphan/dangling lint. Two thin
2
+ // commands over wiki/run-wiki.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runWikiLint, runWikiServe } from '../wiki/run-wiki.ts'
5
+
6
+ export class WikiServeCommand extends Command {
7
+ static override paths = [['wiki', 'serve']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Wiki',
11
+ description: 'live md link-graph server — reload = rescan',
12
+ examples: [
13
+ ['serve the cwd wiki', 'shipkit wiki serve'],
14
+ ['serve a subtree on a port', 'shipkit wiki serve --root agent --index process.md --port 6162'],
15
+ ],
16
+ })
17
+
18
+ root = Option.String('--root', '.', { description: 'wiki root directory' })
19
+ port = Option.String('--port', '6161', { description: 'server port' })
20
+ title = Option.String('--title', { required: false, description: 'page title shown in the graph header' })
21
+ index = Option.String('--index', { required: false, description: 'entrypoint page (default: index.md AND README.md)' })
22
+ skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
23
+
24
+ async execute(): Promise<number> {
25
+ const port = Number(this.port)
26
+ if (!Number.isInteger(port) || port <= 0) {
27
+ this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
28
+ return 1
29
+ }
30
+ return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title })
31
+ }
32
+ }
33
+
34
+ export class WikiLintCommand extends Command {
35
+ static override paths = [['wiki', 'lint']]
36
+
37
+ static override usage = Command.Usage({
38
+ category: 'Wiki',
39
+ description: 'fail on orphan pages and dangling refs (exit 1)',
40
+ examples: [
41
+ ['lint the cwd wiki', 'shipkit wiki lint'],
42
+ ['lint a subtree', 'shipkit wiki lint --root agent --index process.md'],
43
+ ],
44
+ })
45
+
46
+ root = Option.String('--root', '.', { description: 'wiki root directory' })
47
+ index = Option.String('--index', { required: false, description: 'entrypoint page (default: index.md AND README.md)' })
48
+ skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
49
+
50
+ async execute(): Promise<number> {
51
+ return runWikiLint({ root: this.root, index: this.index, skip: this.skip })
52
+ }
53
+ }
package/src/cli.ts CHANGED
@@ -1,28 +1,45 @@
1
1
  #!/usr/bin/env bun
2
- // shipkit — dispatch only; one command per file in cli/.
3
- const cmd = process.argv[2]
2
+ // shipkit — clipanion entry: one command per file in cli/ (cmd_<Name>.ts),
3
+ // logic in plain modules the commands call. runExit sets the exit code
4
+ // without force-exiting, so serve commands keep the process alive.
5
+ import { readFileSync } from 'fs'
6
+ import { Builtins, Cli } from 'clipanion'
7
+ import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
+ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand, AssetsServeCommand } from './cli/cmd_Assets.ts'
9
+ import { CheckCommand } from './cli/cmd_Check.ts'
10
+ import { CiCommand } from './cli/cmd_Ci.ts'
11
+ import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
+ import { InitCommand } from './cli/cmd_Init.ts'
13
+ import { TuiCommand } from './cli/cmd_Tui.ts'
14
+ import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
4
15
 
5
- const run = async (): Promise<number> => {
6
- const args = process.argv.slice(3)
7
- switch (cmd) {
8
- case 'ci': return (await import('./cli/shipkit-ci.ts')).main(args)
9
- case 'check': return (await import('./cli/shipkit-check.ts')).main(args)
10
- case 'contracts': return (await import('./cli/shipkit-contracts.ts')).main(args)
11
- case 'asks': return (await import('./cli/shipkit-asks.ts')).main(args)
12
- case 'ask': return (await import('./cli/shipkit-asks.ts')).main(['new', ...args])
13
- case 'wiki': return (await import('./cli/shipkit-wiki.ts')).main(args)
14
- case 'init': return (await import('./cli/shipkit-init.ts')).main(args)
15
- default:
16
- console.log(`shipkit — repo toolkit
17
- usage: shipkit <command>
18
- ci commit gate: floor + contracts + repo tasks [--bail --skip <group> --with <id> --only <id...> --json]
19
- check periodic health: contracts + repo checks, TTL'd [--json --fresh]
20
- contracts contract fulfillness alone [--venue <v> --json]
21
- asks list open asks · asks check · ask "<title>" [--kind --blocks --check "<cmd>"]
22
- wiki serve|lint — md-wiki graph + link hygiene
23
- init scaffold repo.config.ts + package.json scripts`)
24
- return 2
25
- }
26
- }
16
+ const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
27
17
 
28
- process.exit(await run())
18
+ const cli = new Cli({
19
+ binaryLabel: 'shipkit — repo toolkit',
20
+ binaryName: 'shipkit',
21
+ binaryVersion: version,
22
+ enableCapture: true, // console output pipes into the command streams (testable)
23
+ enableColors: true,
24
+ })
25
+
26
+ cli.register(Builtins.HelpCommand)
27
+ cli.register(Builtins.VersionCommand)
28
+ cli.register(CiCommand)
29
+ cli.register(CheckCommand)
30
+ cli.register(TuiCommand)
31
+ cli.register(ContractsCommand)
32
+ cli.register(AsksCommand)
33
+ cli.register(AsksCheckCommand)
34
+ cli.register(AskNewCommand)
35
+ cli.register(WikiServeCommand)
36
+ cli.register(WikiLintCommand)
37
+ cli.register(AssetsServeCommand)
38
+ cli.register(AssetsFillCommand)
39
+ cli.register(AssetsAddCommand)
40
+ cli.register(AssetsGenCommand)
41
+ cli.register(AssetsIdentityCommand)
42
+ cli.register(AssetsCheckCommand)
43
+ cli.register(InitCommand)
44
+
45
+ cli.runExit(process.argv.slice(2))
@@ -0,0 +1,33 @@
1
+ // contract fulfillness alone, fast and filesystem-only. Plain module
2
+ // behind cmd_Contracts; venue deploy scripts run it before any upload.
3
+ import { activeContracts, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
4
+ import { evaluateContracts } from './contracts-engine.ts'
5
+ import { formatLine } from '../core/wire.ts'
6
+
7
+ export type ContractsOpts = {
8
+ venue?: string
9
+ json?: boolean
10
+ /** line sinks — commands pass their context streams; default to console */
11
+ print?: (line: string) => void
12
+ printErr?: (line: string) => void
13
+ }
14
+
15
+ export async function runContracts(root: string, o: ContractsOpts = {}): Promise<number> {
16
+ const print = o.print ?? ((l: string) => console.log(l))
17
+ const printErr = o.printErr ?? ((l: string) => console.error(l))
18
+ const c = await loadRepoConfig(root)
19
+ let manifests = activeContracts(c)
20
+ if (o.venue != null) {
21
+ manifests = manifests.filter((m) => m.venue === o.venue)
22
+ if (manifests.length === 0) { printErr(`FAIL: unknown venue '${o.venue}'`); return 1 }
23
+ }
24
+ const r = await evaluateContracts(manifests, contractsConfigFrom(c, root))
25
+ if (o.json === true) {
26
+ print(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
27
+ return r.exitCode
28
+ }
29
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
30
+ for (const l of r.lines) print(formatLine(l, color))
31
+ if (r.lines.length === 0) print('ok: no contracts apply (nothing declared, nothing detected)')
32
+ return r.exitCode
33
+ }
@@ -1,6 +1,6 @@
1
1
  // CheckCtx — the per-check handle a probe's logic receives: outcome lines
2
2
  // (wire.ts kinds), guarded fetch/exec helpers, resolved-tool lookup.
3
- import { existsSync } from 'fs'
3
+ import { spawnWithTimeout } from './spawn-timeout.ts'
4
4
  import type { Line } from './wire.ts'
5
5
 
6
6
  // thrown by t.fetch on 429 — the suite converts it to WARN, never FAIL
@@ -43,21 +43,8 @@ export class CheckCtx {
43
43
  }
44
44
 
45
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)
46
+ // timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
47
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 }
48
+ return spawnWithTimeout(cmd, { timeoutMs: opts?.timeoutMs ?? 15_000, env: opts?.env, cwd: opts?.cwd })
62
49
  }
63
50
  }
@@ -27,6 +27,8 @@ export type SecretSpec = {
27
27
  skipHint: string
28
28
  }
29
29
 
30
+ export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
31
+
30
32
  export type CheckSuiteOpts = {
31
33
  name: string
32
34
  cacheDir: string
@@ -35,6 +37,9 @@ export type CheckSuiteOpts = {
35
37
  getSecret?: GetSecret
36
38
  /** env var that busts the cache when set (consumer names it) */
37
39
  freshEnv?: string
40
+ /** per-check completion tap (the TUI streams results in) — cached replays
41
+ * report cached:true, prep-skips (unstored secret) report status 'skip' */
42
+ onResult?: (name: string, r: CheckOutcome) => void
38
43
  }
39
44
 
40
45
  export class CheckSuite {
@@ -45,6 +50,7 @@ export class CheckSuite {
45
50
  private now: () => number
46
51
  private getSecret: GetSecret
47
52
  private freshEnv: string
53
+ private onResult?: (name: string, r: CheckOutcome) => void
48
54
 
49
55
  constructor(opts: CheckSuiteOpts) {
50
56
  this.name = opts.name
@@ -52,6 +58,7 @@ export class CheckSuite {
52
58
  this.now = opts.now ?? Date.now
53
59
  this.getSecret = opts.getSecret ?? (() => null)
54
60
  this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
61
+ this.onResult = opts.onResult
55
62
  }
56
63
 
57
64
  addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; logic: Logic }): void {
@@ -124,7 +131,11 @@ export class CheckSuite {
124
131
 
125
132
  for (const r of this.runners) {
126
133
  const prep = r.prep?.() ?? {}
127
- if (prep.skip != null) { skipped.push(prep.skip); continue }
134
+ if (prep.skip != null) {
135
+ skipped.push(prep.skip)
136
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
137
+ continue
138
+ }
128
139
  ran++
129
140
  const key = prep.cacheExtra ?? ''
130
141
  const entry = cache[r.name]
@@ -133,7 +144,9 @@ export class CheckSuite {
133
144
  && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
134
145
  if (fresh) {
135
146
  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)))
147
+ const lines = entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${age} ago)` } : l))
148
+ out.push(...lines)
149
+ this.onResult?.(r.name, { lines, status: 'pass', cached: true }) // only clean passes are cached
137
150
  continue
138
151
  }
139
152
  const t = new CheckCtx(this.toolPaths)
@@ -145,6 +158,7 @@ export class CheckSuite {
145
158
  else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
146
159
  }
147
160
  out.push(...t.lines)
161
+ this.onResult?.(r.name, { lines: t.lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
148
162
  if (t.failed) anyFail = true
149
163
  if (r.ttlMs > 0) {
150
164
  if (!t.failed && !t.warned) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
@@ -0,0 +1,48 @@
1
+ // *.check.ts auto-discovery — THE way health checks register (repo.config
2
+ // `checks:[]` is dead, one-runner GO 2026-07-22): drop a `<name>.check.ts`
3
+ // anywhere in the repo and `shipkit check` runs it as a subprocess — wire
4
+ // output parsed back, exit code is truth. Skipped dirs: node_modules, dist,
5
+ // tmp, references (templates for other repos), and every dot-dir (.git,
6
+ // caches, .rv-* contract folders).
7
+
8
+ import { readdirSync, readFileSync } from 'fs'
9
+ import { basename, join, relative } from 'path'
10
+
11
+ const IGNORED_DIRS = new Set(['node_modules', 'dist', 'tmp', 'references'])
12
+ const SUFFIX = '.check.ts'
13
+
14
+ export type DiscoveredCheck = {
15
+ /** absolute path to the check file */
16
+ path: string
17
+ /** display + cache name — file base, disambiguated by relative path on collision */
18
+ name: string
19
+ /** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
20
+ selfCached: boolean
21
+ }
22
+
23
+ export function discoverChecks(root: string): DiscoveredCheck[] {
24
+ const files: string[] = []
25
+ const walk = (dir: string): void => {
26
+ let entries
27
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
28
+ for (const e of entries) {
29
+ if (e.isDirectory()) {
30
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name))
31
+ } else if (e.name.endsWith(SUFFIX)) files.push(join(dir, e.name))
32
+ }
33
+ }
34
+ walk(root)
35
+ files.sort()
36
+ const baseOf = (f: string): string => basename(f).slice(0, -SUFFIX.length)
37
+ const seen = new Map<string, number>()
38
+ for (const f of files) seen.set(baseOf(f), (seen.get(baseOf(f)) ?? 0) + 1)
39
+ return files.map((f) => {
40
+ const base = baseOf(f)
41
+ const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
42
+ // a check built on CheckSuite/SkillValidator replays from its OWN
43
+ // TTL cache — caching it again up here would double the staleness
44
+ let selfCached = false
45
+ try { selfCached = /SkillValidator|CheckSuite/.test(readFileSync(f, 'utf8')) } catch {}
46
+ return { path: f, name, selfCached }
47
+ })
48
+ }
@@ -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
+ }