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.
- package/CHANGELOG.md +137 -0
- package/README.md +23 -8
- package/package.json +15 -3
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +179 -0
- package/src/assets/kits/identity-kit.ts +119 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +142 -0
- package/src/assets/server/studio-page.ts +620 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +412 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Serve.ts +34 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +44 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +82 -15
- package/src/serve/hub-pages.ts +84 -0
- package/src/serve/run-serve.ts +85 -0
- package/src/serve/serve-hub.ts +77 -0
- package/src/shipkit.ts +39 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -41
- 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
|
+
}
|
package/src/core/task-runner.ts
CHANGED
|
@@ -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,
|
|
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
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
package/src/init-repo.ts
ADDED
|
@@ -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
|
+
}
|
package/src/repo-config.ts
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
// defineRepo — the ONE import a repo needs: repo.config.ts at the root
|
|
2
|
-
// declares venues/social/tasks
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
|
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):
|
|
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 ?? [])]
|
|
185
|
+
return { ...floor, tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])] }
|
|
119
186
|
}
|