rvlib-shipkit 0.1.1 → 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.
- package/CHANGELOG.md +77 -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 +161 -0
- package/src/assets/kits/identity-kit.ts +80 -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 +122 -0
- package/src/assets/server/studio-page.ts +587 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +360 -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_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +42 -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 +68 -16
- package/src/shipkit.ts +34 -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
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,22 @@
|
|
|
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'
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
|
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):
|
|
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 ?? [])]
|
|
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,
|
|
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 {
|
|
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
|
+
})
|