rvlib-shipkit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +23 -8
  3. package/package.json +15 -3
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +179 -0
  11. package/src/assets/kits/identity-kit.ts +119 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +142 -0
  16. package/src/assets/server/studio-page.ts +620 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +412 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Serve.ts +34 -0
  27. package/src/cli/cmd_Tui.ts +36 -0
  28. package/src/cli/cmd_Wiki.ts +53 -0
  29. package/src/cli.ts +44 -25
  30. package/src/contracts/run-contracts.ts +33 -0
  31. package/src/core/check-ctx.ts +3 -16
  32. package/src/core/check-suite.ts +16 -2
  33. package/src/core/discover-checks.ts +48 -0
  34. package/src/core/run-check.ts +73 -0
  35. package/src/core/run-ci.ts +54 -0
  36. package/src/core/spawn-timeout.ts +65 -0
  37. package/src/core/task-runner.ts +36 -43
  38. package/src/core/wire.ts +20 -0
  39. package/src/core/write-status.ts +70 -0
  40. package/src/init-repo.ts +60 -0
  41. package/src/repo-config.ts +82 -15
  42. package/src/serve/hub-pages.ts +84 -0
  43. package/src/serve/run-serve.ts +85 -0
  44. package/src/serve/serve-hub.ts +77 -0
  45. package/src/shipkit.ts +39 -3
  46. package/src/tui/checks-panel.ts +39 -0
  47. package/src/tui/tasks-panel.ts +54 -0
  48. package/src/tui/tui-app.tsx +91 -0
  49. package/src/tui/tui-panel.ts +57 -0
  50. package/src/tui/tui-store.ts +98 -0
  51. package/src/wiki/run-wiki.ts +50 -0
  52. package/src/cli/shipkit-asks.ts +0 -26
  53. package/src/cli/shipkit-check.ts +0 -26
  54. package/src/cli/shipkit-ci.ts +0 -30
  55. package/src/cli/shipkit-contracts.ts +0 -26
  56. package/src/cli/shipkit-init.ts +0 -41
  57. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,84 @@
1
+ // The hub's two no-build pages (served by serve-hub.ts): the landing page
2
+ // whose links ARE the toolbox, and the /status page (STATUS.md + a
3
+ // "run check" button streaming wire lines). Same style family as the
4
+ // studio: one template string, vanilla JS, no build step, no CDN.
5
+ import type { HubTool } from './serve-hub.ts'
6
+
7
+ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
8
+
9
+ const SHELL = `
10
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
11
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
12
+ *{box-sizing:border-box}
13
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
14
+ .wrap{max-width:860px;margin:0 auto;padding:40px 24px}
15
+ h1{font-size:20px;margin:0}
16
+ h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
17
+ .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
18
+ .crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
19
+ `
20
+
21
+ export function landingPage(o: { name: string; port: number; tools: HubTool[] }): string {
22
+ const cards = o.tools
23
+ .map((t) => `<a class=card href="${esc(t.path)}"><b>${esc(t.label)}</b><span>${esc(t.desc)}</span><code>${esc(t.path)}</code></a>`)
24
+ .join('\n')
25
+ return `<!doctype html><html><head><meta charset=utf-8>
26
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · shipkit</title>
27
+ <style>${SHELL}
28
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:26px}
29
+ .card{display:flex;flex-direction:column;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
30
+ padding:16px;text-decoration:none;color:var(--ink)}
31
+ .card:hover{border-color:var(--accent);background:var(--panel2)}
32
+ .card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
33
+ .card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
34
+ </style></head><body><div class=wrap>
35
+ <div class=crumb>shipkit hub</div>
36
+ <h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
37
+ <div class=cards>
38
+ ${cards}
39
+ </div>
40
+ </div></body></html>`
41
+ }
42
+
43
+ export function statusPage(o: { name: string; statusMd: string | null }): string {
44
+ const body = o.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)'
45
+ return `<!doctype html><html><head><meta charset=utf-8>
46
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · status</title>
47
+ <style>${SHELL}
48
+ .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
49
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
50
+ button:disabled{opacity:.45;cursor:default}
51
+ #note{font-size:12px;color:var(--dim)}
52
+ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
53
+ font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
54
+ .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
55
+ </style></head><body><div class=wrap>
56
+ <div class=crumb><a href="/">shipkit hub</a> / status</div>
57
+ <h1>${esc(o.name)}<small>STATUS.md</small></h1>
58
+ <div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
59
+ <pre id=out></pre>
60
+ <script>
61
+ const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
62
+ // wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
63
+ const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;')
64
+ const cls=l=>{const t=l.trimStart()
65
+ if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
66
+ if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
67
+ if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
68
+ if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
69
+ return ''}
70
+ const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
71
+ paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
72
+ btn.onclick=async()=>{
73
+ btn.disabled=true;note.textContent='running…';let text=''
74
+ try{
75
+ const r=await fetch('status/run',{method:'POST'})
76
+ const rd=r.body.getReader(),dec=new TextDecoder()
77
+ for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
78
+ note.textContent='done — STATUS.md refreshed on disk'
79
+ }catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
80
+ btn.disabled=false
81
+ }
82
+ </script>
83
+ </div></body></html>`
84
+ }
@@ -0,0 +1,85 @@
1
+ // shipkit serve — resolve the repo's toolbox and run the hub. What mounts
2
+ // comes from convention + repo.config's `serve` block: the wiki graph is
3
+ // always on (custom provider via serve.wikiGraph, else the generic md
4
+ // scan), agent/ graph when the dir exists, the studio when .rv-assets/
5
+ // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
+ // default (4800-4979, hashed from the repo name — stable across launches;
7
+ // pin serve.port when two repos collide).
8
+ import { existsSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { fileURLToPath } from 'url'
11
+ import { loadKit } from '../assets/kit-config.ts'
12
+ import { page as studioPage } from '../assets/server/studio-page.ts'
13
+ import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
15
+ import { scanMdGraph } from '../wiki/scan-md.ts'
16
+ import { serveHub, type HubDeps } from './serve-hub.ts'
17
+
18
+ export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
19
+
20
+ export function defaultHubPort(name: string): number {
21
+ let h = 0
22
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
23
+ return 4800 + (h % 180)
24
+ }
25
+
26
+ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
27
+ const wikiGraph = c.serve?.wikiGraph != null
28
+ ? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
29
+ : { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
30
+ const agentRoot = join(root, 'agent')
31
+ const agentGraph = existsSync(agentRoot)
32
+ ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
33
+ : null
34
+ let studio: StudioRoutes | null = null
35
+ if (existsSync(join(root, '.rv-assets/kit.ts'))) {
36
+ // a broken kit must not take the graphs and status down with it —
37
+ // WARN + mount everything else
38
+ try {
39
+ studio = await studioRoutes({ kit: await loadKit(root), root, page: studioPage })
40
+ } catch (e) {
41
+ io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
42
+ }
43
+ }
44
+ const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
45
+ return {
46
+ root,
47
+ name: c.name,
48
+ port: o?.port ?? c.serve?.port ?? defaultHubPort(c.name),
49
+ wikiGraph,
50
+ agentGraph,
51
+ studio,
52
+ checkCmd: [process.execPath, cli, 'check'],
53
+ }
54
+ }
55
+
56
+ export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
57
+ const c = await loadRepoConfig(root)
58
+ const deps = await resolveHubDeps(root, c, io, o)
59
+ try {
60
+ const hub = serveHub(deps)
61
+ const base = `http://localhost:${hub.server.port}`
62
+ io.print(`ok: ${c.name} hub → ${base}`)
63
+ for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
64
+ await new Promise(() => {}) // the hub owns the process — never resolve
65
+ return 0
66
+ } catch (e) {
67
+ if (e instanceof Error && e.message.includes('in use')) {
68
+ // same repo in another window is fine; a different repo hashed
69
+ // onto this port is a real collision — never claim its hub
70
+ const owner = await fetch(`http://127.0.0.1:${deps.port}/hub.json`, { signal: AbortSignal.timeout(1500) })
71
+ .then((r) => r.json() as Promise<{ hub?: string; name?: string; tools?: { path: string; label: string }[] }>)
72
+ .catch(() => null)
73
+ if (owner?.hub === 'shipkit' && owner.name === c.name) {
74
+ // a re-run terminal stays as clickable as a first run — the
75
+ // owner's /hub.json tool list is the same source of truth
76
+ io.print(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
77
+ for (const t of owner.tools ?? []) io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
78
+ return 0
79
+ }
80
+ io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
81
+ return 1
82
+ }
83
+ throw e
84
+ }
85
+ }
@@ -0,0 +1,77 @@
1
+ // shipkit serve — ONE hub server per repo, ONE port (his call 2026-07-22:
2
+ // "a single shipkit task in vscode with multiple urls I can click"). The
3
+ // landing page's links are the whole toolbox: /graph/wiki · /graph/agent ·
4
+ // /assets/ (the studio, mounted IN-PROCESS) · /status (STATUS.md + run
5
+ // check). Route logic only — resolving WHAT to mount from repo.config is
6
+ // run-serve.ts's job.
7
+ import { existsSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
10
+ import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
11
+ import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
+ import { landingPage, statusPage } from './hub-pages.ts'
13
+
14
+ export type HubGraph = { title: string; load: () => MdGraph }
15
+ export type HubTool = { path: string; label: string; desc: string }
16
+ export type HubDeps = {
17
+ root: string
18
+ name: string
19
+ /** 0 = random (tests); real launches pass repo.config/default */
20
+ port: number
21
+ wikiGraph: HubGraph
22
+ /** null = no agent/ dir */
23
+ agentGraph: HubGraph | null
24
+ /** null = no .rv-assets/kit.ts (or it failed to load — caller prints the WARN) */
25
+ studio: StudioRoutes | null
26
+ /** argv shelling the check run streamed by /status/run (test seam) */
27
+ checkCmd: string[]
28
+ }
29
+ export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
30
+
31
+ // the toolbox as data — the landing page, /hub.json, and the stdout ok:
32
+ // lines all render THIS list, so they can never disagree
33
+ export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>): HubTool[] {
34
+ const tools: HubTool[] = [{ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title }]
35
+ if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
36
+ if (d.studio != null) tools.push({ path: '/assets/', label: 'assets studio', desc: 'roll, curate, pick, ship' })
37
+ tools.push({ path: '/status', label: 'status', desc: 'STATUS.md + run check' })
38
+ return tools
39
+ }
40
+
41
+ export function serveHub(d: HubDeps): Hub {
42
+ const tools = hubTools(d)
43
+ const html = (s: string): Response => new Response(s, { headers: { 'content-type': 'text/html; charset=utf-8' } })
44
+ const server: ReturnType<typeof Bun.serve> = Bun.serve({
45
+ hostname: '127.0.0.1',
46
+ port: d.port,
47
+ async fetch(req): Promise<Response> {
48
+ const path = new URL(req.url).pathname
49
+ if (path === '/') return html(landingPage({ name: d.name, port: server.port ?? d.port, tools }))
50
+ // identity probe: lets a second launch tell "same repo, another
51
+ // window" from "a DIFFERENT repo hashed onto my port"
52
+ if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
53
+ if (path === '/graph/wiki') return html(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
54
+ if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
55
+ if (d.agentGraph != null && path === '/graph/agent') return html(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
56
+ if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
57
+ if (path === '/status') {
58
+ const p = join(d.root, 'STATUS.md')
59
+ return html(statusPage({ name: d.name, statusMd: existsSync(p) ? await Bun.file(p).text() : null }))
60
+ }
61
+ if (path === '/status/run' && req.method === 'POST') {
62
+ // stream wire lines as the check emits them; sh merges stderr
63
+ // so FAIL lines reach the page, not just the terminal
64
+ const proc = Bun.spawn(['sh', '-c', 'exec "$0" "$@" 2>&1', ...d.checkCmd], { cwd: d.root, stdout: 'pipe', stderr: 'ignore' })
65
+ return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
66
+ }
67
+ if (d.studio != null) {
68
+ // the studio page fetches RELATIVE urls — it must live under
69
+ // the slash-terminated prefix for them to resolve
70
+ if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
71
+ if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
72
+ }
73
+ return new Response('not found', { status: 404 })
74
+ },
75
+ })
76
+ return { server, tools, stop: () => server.stop(true) }
77
+ }
package/src/shipkit.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  // rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
2
2
  // everything else is the machinery behind the shipkit CLI.
3
3
  export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
4
- export type { RepoConfig, RepoCheck } from './repo-config.ts'
4
+ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './repo-config.ts'
5
+ // the hub (`shipkit serve`): one server, one port, every tool a click away
6
+ export { serveHub, hubTools } from './serve/serve-hub.ts'
7
+ export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
8
+ export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
9
+ export { landingPage, statusPage } from './serve/hub-pages.ts'
10
+ export { discoverChecks } from './core/discover-checks.ts'
11
+ export type { DiscoveredCheck } from './core/discover-checks.ts'
5
12
  export { runTasks, selectTasks } from './core/task-runner.ts'
6
13
  export type { TaskDef, TaskResult, RunOpts, RunSummary } from './core/task-runner.ts'
7
14
  export { CheckSuite } from './core/check-suite.ts'
8
- export type { CheckSuiteOpts, GetSecret, SecretSpec } from './core/check-suite.ts'
15
+ export type { CheckSuiteOpts, CheckOutcome, GetSecret, SecretSpec } from './core/check-suite.ts'
16
+ // tui: type-only here — the store/view/panels import ink+mobx, which must
17
+ // not load on every `shipkit ci`; runtime pieces live at rvlib-shipkit/src/tui/*
18
+ export type { TuiPanelDef, TuiRow, TuiRowStatus, MarkedLine } from './tui/tui-panel.ts'
9
19
  export { CheckCtx, RateLimited, ToolUnresolved } from './core/check-ctx.ts'
10
- export { formatLine, parseDuration, fmtAge } from './core/wire.ts'
20
+ export { spawnWithTimeout } from './core/spawn-timeout.ts'
21
+ export type { SpawnOutcome, SpawnTimeoutOpts } from './core/spawn-timeout.ts'
22
+ export { writeStatus } from './core/write-status.ts'
23
+ export type { StatusInput, StatusOutcome } from './core/write-status.ts'
24
+ export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
11
25
  export type { Line } from './core/wire.ts'
12
26
  export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
13
27
  export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
@@ -16,6 +30,28 @@ export type { ContractManifest, ArtifactRule, Detector, ContractEnv } from './co
16
30
  export { allContracts } from './contracts/contract-manifests.ts'
17
31
  export { listAsks, openAsks, checkAsks, createAsk, renderAsksTable } from './asks/asks-queue.ts'
18
32
  export type { Ask, CreateOpts } from './asks/asks-queue.ts'
33
+ // assets surface (folded in from the standalone atelier lib; that npm
34
+ // package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
35
+ // declares its kinds; `shipkit assets …` derives the rest.
36
+ export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
37
+ export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
38
+ export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
39
+ export type { Brand } from './assets/kits/identity-kit.ts'
40
+ export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
41
+ export type { Studio, StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
42
+ export { page as studioPage } from './assets/server/studio-page.ts'
43
+ export { StudioQueue } from './assets/server/studio-queue.ts'
44
+ export type { Job, QueueState } from './assets/server/studio-queue.ts'
45
+ export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'
46
+ export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
47
+ export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
48
+ export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
49
+ export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
50
+ export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
51
+ export { toPng, alphaHeight } from './assets/image-ops.ts'
52
+ export { updateJson, readJson } from './assets/json-store.ts'
53
+ export { assetDir, listRolls, isRollFile, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
54
+ export type { PickState, PromptOverlay } from './assets/roll-workspace.ts'
19
55
  export { scanMdGraph } from './wiki/scan-md.ts'
20
56
  export { lintMd } from './wiki/lint-md.ts'
21
57
  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
+ })
@@ -0,0 +1,57 @@
1
+ // TUI panel contract — render-free and mobx-free on purpose: a panel is
2
+ // DATA (rows + wire-format detail lines), the generic view (tui-app.tsx)
3
+ // owns all ink. Built-ins: tasks-panel.ts (the ci gate) + checks-panel.ts
4
+ // (contracts + discovered *.check.ts); a repo injects extras via
5
+ // repo.config `tui: { panels: [...] }`.
6
+
7
+ export type TuiRowStatus = 'pass' | 'fail' | 'warn' | 'skip' | 'pending' | 'info'
8
+
9
+ export type TuiRow = {
10
+ key: string
11
+ /** left-list label */
12
+ label: string
13
+ /** drives the list mark + detail border color; 'pending' shows the spinner */
14
+ status: TuiRowStatus
15
+ /** dim right-side hint (duration, age, cached, owner) */
16
+ hint?: string
17
+ /** list-mark override (default derives from status — asks rows use '?') */
18
+ mark?: string
19
+ /** detail-panel lines — wire format (ok:/WARN/FAIL:/skip:/fix:) or plain text */
20
+ detail: string[]
21
+ }
22
+
23
+ export type TuiPanelDef = {
24
+ id: string
25
+ /** tab label */
26
+ title: string
27
+ /** shown green when rows() is empty after refresh (e.g. "no open asks …") */
28
+ emptyText?: string
29
+ /** current rows — the store re-reads after every update() */
30
+ rows: () => TuiRow[]
31
+ /** (re)load data; call update() after each mutation so the view repaints.
32
+ * The spinner runs until every panel's returned promise settled. */
33
+ refresh: (update: () => void) => void | Promise<void>
34
+ }
35
+
36
+ export const ROW_MARK: Record<TuiRowStatus, { mark: string; color?: string }> = {
37
+ pass: { mark: '✓', color: 'green' },
38
+ fail: { mark: '✗', color: 'red' },
39
+ warn: { mark: '⚠', color: 'yellow' },
40
+ skip: { mark: '~', color: 'gray' },
41
+ pending: { mark: '', color: 'cyan' }, // the view substitutes the spinner frame
42
+ info: { mark: '·' },
43
+ }
44
+
45
+ // normalize a wire/detail line to the one visual language (✓/✗/⚠/~/»),
46
+ // no raw 'ok'/'FAIL' prefixes in the detail panel
47
+ export type MarkedLine = { mark: string; color?: string; text: string; bold?: boolean }
48
+
49
+ export const markLine = (l: string): MarkedLine => {
50
+ if (/^ok[:\s]/.test(l)) return { mark: '✓', color: 'green', text: l.replace(/^ok[:\s]+/, '') }
51
+ if (l.startsWith('FAIL')) return { mark: '✗', color: 'red', text: l.replace(/^FAIL[:\s]+/, '') }
52
+ if (l.startsWith('WARN')) return { mark: '⚠', color: 'yellow', text: l.replace(/^WARN[:\s]+/, '') }
53
+ if (l.startsWith('skip:')) return { mark: '~', color: 'gray', text: l.replace(/^skip:\s*/, '') }
54
+ if (/^\s+fix: /.test(l)) return { mark: '»', color: 'yellow', text: l.trim() }
55
+ if (l.startsWith('VERDICT')) return { mark: '»', text: l, bold: true }
56
+ return { mark: ' ', text: l }
57
+ }
@@ -0,0 +1,98 @@
1
+ // TUI state — MobX per the house playbook (build/state + build/mobx):
2
+ // `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
3
+ // decorators), runInAction around every post-await mutation. Panels are
4
+ // plain data providers (no mobx): they signal mutations through update(),
5
+ // which bumps the observable `version` counter every rows() read tracks.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { TuiPanelDef, TuiRow, TuiRowStatus } from './tui-panel.ts'
8
+
9
+ export class TuiStore {
10
+ tab = 0
11
+ sel = 0
12
+ spin = 0 // spinner frame, ticked while any panel refresh is in flight
13
+ version = 0 // bumped by panel update() — activeRows re-derives from it
14
+ error: string | null = null
15
+ inflight = 0 // panel refresh promises not yet settled
16
+
17
+ readonly name: string
18
+ readonly panels: TuiPanelDef[]
19
+ private spinTimer: ReturnType<typeof setInterval> | null = null
20
+
21
+ constructor(name: string, panels: TuiPanelDef[]) {
22
+ if (panels.length === 0) throw new Error('TuiStore: at least one panel required')
23
+ this.name = name
24
+ this.panels = panels
25
+ // panels/name/spinTimer are infra, never observed
26
+ makeAutoObservable<TuiStore, 'spinTimer'>(this, { name: false, panels: false, spinTimer: false })
27
+ }
28
+
29
+ get activePanel(): TuiPanelDef {
30
+ return this.panels[this.tab]!
31
+ }
32
+
33
+ get activeRows(): TuiRow[] {
34
+ void this.version // panels mutate outside mobx — update() bumps this
35
+ return this.activePanel.rows()
36
+ }
37
+
38
+ get selectedRow(): TuiRow | null {
39
+ return this.activeRows[this.sel] ?? null
40
+ }
41
+
42
+ /** per-status counts over the ACTIVE panel — the header summary */
43
+ get counts(): Record<TuiRowStatus, number> {
44
+ const c: Record<TuiRowStatus, number> = { pass: 0, fail: 0, warn: 0, skip: 0, pending: 0, info: 0 }
45
+ for (const r of this.activeRows) c[r.status]++
46
+ return c
47
+ }
48
+
49
+ rowsOf(panel: TuiPanelDef): TuiRow[] {
50
+ void this.version
51
+ return panel.rows()
52
+ }
53
+
54
+ setTab(i: number): void {
55
+ if (i < 0 || i >= this.panels.length || i === this.tab) return
56
+ this.tab = i
57
+ this.sel = 0
58
+ }
59
+
60
+ cycleTab(delta = 1): void {
61
+ this.setTab((this.tab + delta + this.panels.length) % this.panels.length)
62
+ }
63
+
64
+ move(delta: number): void {
65
+ const max = Math.max(0, this.activeRows.length - 1)
66
+ this.sel = Math.min(max, Math.max(0, this.sel + delta))
67
+ }
68
+
69
+ // kick every panel's refresh; results stream in via update(). A refresh
70
+ // while one is in flight is ignored — panels own no superseding logic.
71
+ refresh(): void {
72
+ if (this.inflight > 0) return
73
+ this.error = null
74
+ this.startSpinner()
75
+ const update = (): void => runInAction(() => { this.version++ })
76
+ for (const p of this.panels) {
77
+ this.inflight++
78
+ void Promise.resolve()
79
+ .then(() => p.refresh(update))
80
+ .catch((e) => runInAction(() => { this.error = `${p.id}: ${String(e).slice(0, 200)}` })) // never fail silently — surfaced in the header
81
+ .finally(() => runInAction(() => {
82
+ this.version++
83
+ if (--this.inflight === 0) this.stopSpinner()
84
+ }))
85
+ }
86
+ }
87
+
88
+ private startSpinner(): void {
89
+ if (this.spinTimer != null) return
90
+ this.spinTimer = setInterval(() => runInAction(() => { this.spin++ }), 120)
91
+ }
92
+
93
+ private stopSpinner(): void {
94
+ if (this.spinTimer == null) return
95
+ clearInterval(this.spinTimer)
96
+ this.spinTimer = null
97
+ }
98
+ }