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.
Files changed (53) hide show
  1. package/CHANGELOG.md +77 -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 +161 -0
  11. package/src/assets/kits/identity-kit.ts +80 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +122 -0
  16. package/src/assets/server/studio-page.ts +587 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +360 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Tui.ts +36 -0
  27. package/src/cli/cmd_Wiki.ts +53 -0
  28. package/src/cli.ts +42 -25
  29. package/src/contracts/run-contracts.ts +33 -0
  30. package/src/core/check-ctx.ts +3 -16
  31. package/src/core/check-suite.ts +16 -2
  32. package/src/core/discover-checks.ts +48 -0
  33. package/src/core/run-check.ts +73 -0
  34. package/src/core/run-ci.ts +54 -0
  35. package/src/core/spawn-timeout.ts +65 -0
  36. package/src/core/task-runner.ts +36 -43
  37. package/src/core/wire.ts +20 -0
  38. package/src/core/write-status.ts +70 -0
  39. package/src/init-repo.ts +60 -0
  40. package/src/repo-config.ts +68 -16
  41. package/src/shipkit.ts +34 -3
  42. package/src/tui/checks-panel.ts +39 -0
  43. package/src/tui/tasks-panel.ts +54 -0
  44. package/src/tui/tui-app.tsx +91 -0
  45. package/src/tui/tui-panel.ts +57 -0
  46. package/src/tui/tui-store.ts +98 -0
  47. package/src/wiki/run-wiki.ts +50 -0
  48. package/src/cli/shipkit-asks.ts +0 -26
  49. package/src/cli/shipkit-check.ts +0 -26
  50. package/src/cli/shipkit-ci.ts +0 -30
  51. package/src/cli/shipkit-contracts.ts +0 -26
  52. package/src/cli/shipkit-init.ts +0 -41
  53. package/src/cli/shipkit-wiki.ts +0 -74
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-07-22
4
+
5
+ The one-runner release: shipkit becomes the all-in-one companion
6
+ binary/cli/tui of every repo — ci gate, health checks, contracts, asks,
7
+ wiki graphs, asset atelier, live dashboard, init.
8
+
9
+ BREAKING:
10
+
11
+ - repo.config `checks:[]` + the `RepoCheck` type are DELETED (zero
12
+ consumers existed) — drop a `<name>.check.ts` file anywhere instead;
13
+ `shipkit check` discovers it.
14
+ - `defineRepo` now REJECTS unknown top-level and `defaults` keys — a typo
15
+ can no longer silently thin the gate.
16
+ - the standalone `rvlib-atelier` npm package is SUPERSEDED by this release
17
+ (its source folded in as `src/assets/`); it stays published as the frozen
18
+ predecessor, no new releases, deprecated after its last consumer swaps.
19
+
20
+ New:
21
+
22
+ - assets: `customPanel({ id, script })` — repo-defined metadata editors in
23
+ the studio. `script` is a repo-root-relative ES module served at
24
+ `GET /panel/<kindId>/<panelId>.js` and mounted as a drawer tab per asset:
25
+ `mount(el, api)` (+ optional `unmount()`), api =
26
+ `{ assetId, kindId, roll, imgUrl, getMeta(), saveMeta(json) }` backed by
27
+ the roll's meta sidecar (`/api/meta`, single writer). Script paths are
28
+ validated at kit load AND studio boot (loud error, never a silent 404);
29
+ panel ids are url-safe kebab-case, no `..`/absolute paths. geometry/grid
30
+ panels stay baked in — this is the escape hatch for any other shape.
31
+
32
+ - tui: `shipkit tui` — ONE live dashboard for every repo. Built-in panels:
33
+ Tasks (the ci gate streamed through the DAG runner via the new
34
+ `onTaskFinish` tap; STATUS.md refreshes like a terminal `shipkit ci`) and
35
+ Checks (contracts + discovered `*.check.ts` via the new CheckSuite
36
+ `onResult` tap — cached replays flagged, TTL layers intact). Panels are
37
+ render-free data (`TuiPanelDef`: rows + wire-format detail lines); repos
38
+ append their own via repo.config `tui: { panels: [...] }`. The view (ink +
39
+ mobx `makeAutoObservable`) normalizes wire lines to one visual language
40
+ (✓/✗/⚠/~/»). `asks.emptyText` config personalizes the `shipkit asks`
41
+ all-clear line.
42
+
43
+ - floor integrity (zero-config O6): with no package.json script,
44
+ `imports:check`/`cycles` fill from the bundled rvlib-ts-tools dep and
45
+ `typecheck` from the repo's tsc; a slot nothing can fill prints a
46
+ `skip: <id> (reason)` line — a thin gate is never silent. `defineRepo`
47
+ now REJECTS unknown top-level and `defaults` keys. `shipkit init` prints
48
+ how each floor slot resolved and gains `--bin <cli.ts path>` for
49
+ in-repo-source consumers (scripts become `bun <path> <cmd>`).
50
+ - STATUS.md: `writeStatus(root, …)` in the lib is THE writer — timestamp +
51
+ RED/GREEN verdict + fenced compact render, atomic (tmp + rename),
52
+ lock-guarded (concurrent runner skips with a WARN; stale locks stolen).
53
+ `shipkit ci` and `shipkit check` refresh it by default (`status: false`
54
+ opts out).
55
+ - check: `*.check.ts` auto-discovery is THE registration — `shipkit check`
56
+ scans the repo (skips node_modules/dist/tmp/references + dot-dirs) and
57
+ runs each file as a subprocess: wire output parsed back (`parseWireOutput`
58
+ exported), exit code is truth, non-zero exit with no FAIL line still fails
59
+ loud. Checks running their own CheckSuite are never double-cached; plain
60
+ checks get a 30m orchestrator TTL; `--fresh` busts every layer and travels
61
+ to children.
62
+ - core: ONE spawn-with-timeout (`spawnWithTimeout`, exported) — task runner
63
+ and `CheckCtx.exec` now share it (binary resolved before spawn, streamed
64
+ output, SIGTERM→SIGKILL escalation); duplicate implementations deleted.
65
+
66
+ - assets: the atelier folded in as the `src/assets/` surface — asset-kind
67
+ registry (defineKit/defineKind), ComfyUI + procedural-canvas generators,
68
+ roll-and-pick studio server, metadata editor panels, kits/identity
69
+ (favicon/logo/OG), `rvlib-shipkit/vite` save-endpoint factory. CLI:
70
+ `shipkit assets serve|fill|add|gen|identity|check`.
71
+ - CLI rebuilt on clipanion (one command per file, `Command.Usage` help with
72
+ categories + examples, testable contexts); wire output contract unchanged
73
+ and frozen by a CLI-level test. Every value-taking option carries a
74
+ description — per-command `--help` renders a clean synopsis + Options
75
+ table.
76
+ - deps: @napi-rs/canvas + sharp (assets), clipanion (CLI), ink + react +
77
+ mobx + mobx-react-lite (tui), rvlib-ts-tools (imports/cycles floor for
78
+ every repo).
79
+
3
80
  ## 0.1.1 — 2026-07-22
4
81
 
5
82
  - `init` now points at the identity/assets setup (rvlib-atelier: brand.ts +
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # rvlib-shipkit
2
2
 
3
3
  Repo toolkit for shipping: one config file gives a repo a commit gate
4
- (task DAG), release-contract fulfillness checks, a human ask queue, and
5
- markdown-wiki tooling.
4
+ (task DAG), release-contract fulfillness checks, a human ask queue,
5
+ markdown-wiki tooling, and the asset atelier (generate, curate, and ship
6
+ assets — favicon/logo/OG included).
6
7
 
7
8
  ```sh
8
9
  bun add -d rvlib-shipkit
@@ -17,20 +18,32 @@ export default defineRepo({
17
18
  name: 'my-game',
18
19
  venues: ['itch'], // which release contracts are REQUIRED
19
20
  social: ['x', 'discord'], // .rv-social/<channel>/ post folders (+ .rv-discord/)
20
- tasks: [{ id: 'typecheck', cmd: ['bun', 'run', 'typecheck'], deps: ['lint'] }],
21
+ tasks: [{ id: 'e2e', cmd: ['bun', 'run', 'e2e'], deps: ['lint'] }],
21
22
  })
22
23
  ```
23
24
 
25
+ Health checks need no config at all: drop a `<name>.check.ts` file anywhere
26
+ and `shipkit check` discovers and runs it (wire output + exit code).
27
+
24
28
  ## CLI
25
29
 
26
30
  ```
27
- shipkit ci commit gate: auto-registered floor (lint/typecheck/test/…)
28
- + contracts + your tasks, parallel DAG [--bail --skip g --only id --json]
31
+ shipkit ci commit gate: floor (lint/typecheck/test/imports/cycles —
32
+ scripts win, imports/cycles/typecheck fill from bundled
33
+ tools with zero config, gaps print visibly) + contracts
34
+ + your tasks, parallel DAG; refreshes STATUS.md
35
+ [--bail --skip g --only id --json]
29
36
  shipkit contracts release-contract fulfillness alone [--venue v --json]
30
- shipkit check periodic health checks, TTL-cached [--json --fresh]
37
+ shipkit check periodic health: every *.check.ts in the repo,
38
+ auto-discovered + TTL-cached [--json --fresh]
39
+ shipkit tui live dashboard: ci tasks + discovered checks + panels
40
+ the repo injects via repo.config tui.panels
31
41
  shipkit asks human action queue: list · ask "<title>" · asks check (auto-close)
32
42
  shipkit wiki serve|lint — live md link-graph + orphan/dangling-ref lint
33
- shipkit init scaffold config + scripts
43
+ shipkit assets serve|fill|add|gen|identity|check — the asset atelier
44
+ (declare kinds in .rv-assets/kit.ts, roll, curate, ship)
45
+ shipkit init scaffold config + scripts, print the resolved floor
46
+ [--bin <cli.ts path> for in-repo-source consumers]
34
47
  ```
35
48
 
36
49
  Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with
@@ -52,7 +65,9 @@ Ship your own contracts via `contracts: { extra: [defineContract({...})] }`
52
65
  ## Library use
53
66
 
54
67
  Everything the CLI does is importable: `runTasks`, `CheckSuite`,
55
- `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`.
68
+ `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`,
69
+ and the assets surface (`defineKit`/`defineKind`, `identityKinds`,
70
+ `serveStudio`, `saveEndpoint` via `rvlib-shipkit/vite`).
56
71
  Ships TypeScript source; Bun ≥ 1.0.
57
72
 
58
73
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.1.1",
4
- "description": "Repo toolkit: task runner, release-contract fulfillness checker, asks queue, md-wiki tools",
3
+ "version": "0.2.0",
4
+ "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "files": [
@@ -15,9 +15,21 @@
15
15
  "exports": {
16
16
  ".": "./src/shipkit.ts",
17
17
  "./contracts": "./src/contracts/contract-types.ts",
18
+ "./vite": "./src/assets/vite.ts",
18
19
  "./src/*": "./src/*"
19
20
  },
20
21
  "devDependencies": {
21
- "@types/bun": "^1.3.14"
22
+ "@types/bun": "^1.3.14",
23
+ "@types/react": "^19.2.17"
24
+ },
25
+ "dependencies": {
26
+ "@napi-rs/canvas": "^1.0.2",
27
+ "clipanion": "^4.0.0-rc.4",
28
+ "ink": "^7.1.1",
29
+ "mobx": "^6.16.1",
30
+ "mobx-react-lite": "^4.1.1",
31
+ "react": "^19.2.7",
32
+ "rvlib-ts-tools": "^0.2.0",
33
+ "sharp": "^0.35.3"
22
34
  }
23
35
  }
@@ -9,7 +9,18 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFile
9
9
  import { join } from 'path'
10
10
 
11
11
  export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
12
- export type CreateOpts = { kind?: string; blocks?: string; check?: string }
12
+ export type CreateOpts = { kind?: string; blocks?: string; check?: string; why?: string }
13
+
14
+ /** filename slug: kebab, truncated at a WORD boundary (never mid-word — a
15
+ * `…-rvlib-` stub reads as broken). Falls back to a hard cut only when the
16
+ * first word alone already exceeds max. */
17
+ export function slugify(title: string, max = 40): string {
18
+ const full = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
19
+ if (full.length <= max) return full
20
+ const cut = full.slice(0, max)
21
+ const lastDash = cut.lastIndexOf('-')
22
+ return (lastDash > 0 ? cut.slice(0, lastDash) : cut).replace(/-+$/, '')
23
+ }
13
24
 
14
25
  const parse = (dir: string, file: string): Ask => {
15
26
  const md = readFileSync(join(dir, file), 'utf8')
@@ -62,10 +73,14 @@ export function createAsk(dir: string, title: string, opts?: CreateOpts): string
62
73
  const done = join(dir, 'done')
63
74
  const archived = existsSync(done) ? readdirSync(done).filter((f) => f.endsWith('.md')) : []
64
75
  const n = Math.max(0, ...[...listAsks(dir).map((a) => a.file), ...archived].map((f) => parseInt(f, 10) || 0)) + 1
65
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
76
+ const slug = slugify(title)
66
77
  const file = `${String(n).padStart(3, '0')}-${slug}.md`
67
78
  if (existsSync(join(dir, file))) throw new Error(`refusing to overwrite existing ${file}`)
68
79
  const check = opts?.check ?? null
80
+ // Why is OPTIONAL — omit the line entirely rather than ship a literal
81
+ // "TODO" that never gets filled (the ask itself is the record).
82
+ const why = opts?.why?.trim()
83
+ const whyBlock = why != null && why !== '' ? `**Why**: ${why}\n\n` : ''
69
84
  writeFileSync(join(dir, file), `---
70
85
  status: open
71
86
  kind: ${opts?.kind ?? 'action'}
@@ -77,18 +92,16 @@ blocks: ${opts?.blocks ?? '-'}${check != null ? `\ncheck: ${check}` : ''}
77
92
 
78
93
  **Ask**: ${title}
79
94
 
80
- **Why**: TODO
81
-
82
- **Answer**: _(fill in / check off, or just do it — the check probe auto-closes when it passes)_
95
+ ${whyBlock}**Answer**: _(fill in / check off, or just do it — the check probe auto-closes when it passes)_
83
96
 
84
97
  **Done when**: ${check != null ? `\`${check}\` exits 0` : 'status flipped to done'}
85
98
  `)
86
99
  return file
87
100
  }
88
101
 
89
- export function renderAsksTable(dir: string, print: (line: string) => void = console.log): number {
102
+ export function renderAsksTable(dir: string, print: (line: string) => void = console.log, opts?: { emptyText?: string }): number {
90
103
  const open = openAsks(dir)
91
- if (open.length === 0) { print('no open asks — all caught up 🎉'); return 0 }
104
+ if (open.length === 0) { print(opts?.emptyText ?? 'no open asks — all caught up 🎉'); return 0 }
92
105
  for (const a of open) print(`${a.status.padEnd(9)} ${a.kind.padEnd(7)} ${a.file.padEnd(44)} blocks: ${a.blocks}`)
93
106
  print(`\n${open.length} open — details: ${dir}/<file>`)
94
107
  return open.length
@@ -0,0 +1,106 @@
1
+ // ComfyUI HTTP engine — post/wait/fetch plumbing, zero node-id knowledge
2
+ // (node ids live in each workflow's .ts sidekick).
3
+ import { mkdirSync } from 'fs'
4
+ import { extname, join } from 'path'
5
+ import type { Graph, Sidekick, Workflow } from './workflow-loader.ts'
6
+
7
+ export type ComfyOpts = {
8
+ /** e.g. 'http://127.0.0.1:8085' */
9
+ host: string
10
+ /** shell command that heals the backend (tunnel/boot) — shown in error hints, never executed by the lib */
11
+ heal?: string
12
+ waitTimeoutMs?: number
13
+ pollMs?: number
14
+ }
15
+
16
+ const WAIT_TIMEOUT_MS = 10 * 60_000 // covers cold model loads
17
+ const POLL_MS = 1500
18
+
19
+ const healHint = (o: ComfyOpts): string => (o.heal != null ? ` — is the backend up? (${o.heal})` : '')
20
+
21
+ /** Loud guard for setInputs escape hatches: the node must exist and match. */
22
+ export function expectNode(graph: Graph, id: string, classType: string): Graph[string] {
23
+ const n = graph[id]
24
+ if (n == null) throw new Error(`graph has no node '${id}' (expected ${classType})`)
25
+ if (n.class_type !== classType) throw new Error(`node '${id}' is ${n.class_type}, expected ${classType}`)
26
+ return n
27
+ }
28
+
29
+ export function applyInputs(graph: Graph, sk: Sidekick, args: { prompt: string; seed: number; prefix: string }): void {
30
+ if (sk.setInputs != null) return sk.setInputs(graph, args)
31
+ for (const [role, value] of [['prompt', args.prompt], ['seed', args.seed], ['prefix', args.prefix]] as const) {
32
+ const b = sk.bindings[role]
33
+ graph[b.node]!.inputs[b.input] = value
34
+ }
35
+ }
36
+
37
+ export async function submit(o: ComfyOpts, graph: Graph): Promise<string> {
38
+ const res = await fetch(`${o.host}/prompt`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ prompt: graph, client_id: crypto.randomUUID() }),
42
+ })
43
+ if (!res.ok) throw new Error(`POST /prompt → ${res.status}: ${(await res.text()).slice(0, 300)} — bad graph or server mid-boot${healHint(o)}`)
44
+ return ((await res.json()) as { prompt_id: string }).prompt_id
45
+ }
46
+
47
+ type HistoryOutputs = Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }>
48
+
49
+ export async function waitFor(o: ComfyOpts, promptId: string): Promise<HistoryOutputs> {
50
+ const timeoutMs = o.waitTimeoutMs ?? WAIT_TIMEOUT_MS
51
+ const deadline = Date.now() + timeoutMs
52
+ while (Date.now() < deadline) {
53
+ const res = await fetch(`${o.host}/history/${promptId}`)
54
+ if (res.ok) {
55
+ const h = (await res.json()) as Record<string, { outputs: HistoryOutputs; status?: { status_str?: string } }>
56
+ const entry = h[promptId]
57
+ if (entry != null) {
58
+ if (entry.status?.status_str === 'error') throw new Error(`prompt ${promptId} errored on the server — open the ComfyUI UI (${o.host}) for the node error`)
59
+ return entry.outputs
60
+ }
61
+ }
62
+ await Bun.sleep(o.pollMs ?? POLL_MS)
63
+ }
64
+ throw new Error(`prompt ${promptId} still not done after ${Math.round(timeoutMs / 60000)} min — server stuck? check ${o.host} queue${healHint(o)}`)
65
+ }
66
+
67
+ async function fetchImage(o: ComfyOpts, img: { filename: string; subfolder?: string; type?: string }, dest: string): Promise<void> {
68
+ const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder ?? '', type: img.type ?? 'output' })
69
+ const res = await fetch(`${o.host}/view?${q}`)
70
+ if (!res.ok) throw new Error(`GET /view ${img.filename} → ${res.status}`)
71
+ await Bun.write(dest, await res.arrayBuffer())
72
+ }
73
+
74
+ /**
75
+ * Run one generation through a workflow pair: fresh graph copy, sidekick
76
+ * bindings applied, submitted, waited, non-preview outputs saved as
77
+ * <destDir>/<name>.<ext> (ONE clean file per asset — no _00001_ counter noise).
78
+ */
79
+ export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string }): Promise<string[]> {
80
+ const graph = structuredClone(wf.graph)
81
+ applyInputs(graph, wf.sidekick, { prompt: args.prompt, seed: args.seed, prefix: args.name })
82
+ mkdirSync(args.destDir, { recursive: true })
83
+ const outputs = await waitFor(o, await submit(o, graph))
84
+ const saved: string[] = []
85
+ for (const node of Object.values(outputs)) {
86
+ for (const img of node.images ?? []) {
87
+ if (img.type === 'temp') continue // PreviewImage — raw, non-transparent
88
+ // >1 saved image (batch_size, second Save node): suffix instead of silently overwriting
89
+ const ext = extname(img.filename) || '.webp'
90
+ const dest = join(args.destDir, `${args.name}${saved.length > 0 ? `-${saved.length + 1}` : ''}${ext}`)
91
+ await fetchImage(o, img, dest)
92
+ saved.push(dest)
93
+ }
94
+ }
95
+ if (saved.length === 0) throw new Error(`workflow '${wf.id}' produced no saved images (only previews?) — does the graph end in a Save node?`)
96
+ return saved
97
+ }
98
+
99
+ export const randomSeed = (): number => Math.floor(Math.random() * 2_000_000_000) + 1
100
+
101
+ /** Shell-stamped batch folder name — the one-shot output convention. */
102
+ export function batchFolderName(slug: string): string {
103
+ const d = new Date()
104
+ const p = (n: number) => String(n).padStart(2, '0')
105
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${slug}`
106
+ }
@@ -0,0 +1,57 @@
1
+ // The ONE roll generator — canvas (procedural, in-process via @napi-rs/canvas)
2
+ // or comfy (HTTP through a workflow pair). The studio worker AND the
3
+ // fill/add/gen CLI both call this, so a canvas kit rolls the SAME way in both
4
+ // (before this, canvas draw lived only in the studio and the CLI refused
5
+ // canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only kits
6
+ // never touch a comfy config or the box.
7
+ import { mkdirSync } from 'fs'
8
+ import { join } from 'path'
9
+ import type { FoundAsset } from '../kit-config.ts'
10
+ import { generate } from './comfy-gen.ts'
11
+ import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
12
+
13
+ /** roll canvas master size (square) — favicon/logo/sprites derive from it */
14
+ export const CANVAS_SIZE = 1024
15
+
16
+ export type ComfyRuntime = { workflows: Map<string, Workflow>; host: string; heal?: string }
17
+ export type ComfyResolver = () => Promise<ComfyRuntime>
18
+
19
+ /** Draw/generate one roll for `found` into destDir as v<seed>.png (canvas) or
20
+ * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
21
+ export async function generateRoll(
22
+ found: FoundAsset,
23
+ args: { prompt: string; seed: number; destDir: string },
24
+ resolveComfy: ComfyResolver,
25
+ ): Promise<void> {
26
+ const gen = found.kind.generate
27
+ if (gen.backend === 'canvas') {
28
+ const { createCanvas } = await import('@napi-rs/canvas')
29
+ const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
30
+ await gen.draw(c.getContext('2d'), { ...found.asset, prompt: args.prompt })
31
+ mkdirSync(args.destDir, { recursive: true })
32
+ await Bun.write(join(args.destDir, `v${args.seed}.png`), c.toBuffer('image/png'))
33
+ return
34
+ }
35
+ const rt = await resolveComfy()
36
+ const wf = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
37
+ await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}` })
38
+ }
39
+
40
+ /** Memoized lazy comfy runtime for a resolved kit — loads the workflow pairs on
41
+ * the FIRST comfy roll only. `onMissing` is called (never returns) if a comfy
42
+ * asset needs it but the kit declares no comfy config. */
43
+ export function comfyResolver(
44
+ root: string,
45
+ kit: { comfy?: { host: string; workflows: string; heal?: string } },
46
+ onMissing: () => never,
47
+ ): ComfyResolver {
48
+ let cached: Promise<ComfyRuntime> | null = null
49
+ return () => {
50
+ if (kit.comfy == null) onMissing()
51
+ return (cached ??= (async () => ({
52
+ workflows: await loadWorkflows(join(root, kit.comfy!.workflows)),
53
+ host: kit.comfy!.host,
54
+ heal: kit.comfy!.heal,
55
+ }))())
56
+ }
57
+ }
@@ -0,0 +1,67 @@
1
+ // Workflow pairs: <id>.json exported graph + <id>.ts sidekick (bindings +
2
+ // optional expects/setInputs). Every violation THROWS loud with the fix in
3
+ // the message — CLIs die, checkers turn it into a FAIL line.
4
+ import { existsSync, readdirSync } from 'fs'
5
+ import { basename, join } from 'path'
6
+
7
+ export type Binding = { node: string; input: string }
8
+ export type Sidekick = {
9
+ bindings: { prompt: Binding; seed: Binding; prefix: Binding }
10
+ expects?: Record<string, string>
11
+ requires?: { customNodes?: string[]; models?: string[] }
12
+ setInputs?: (graph: Graph, args: { prompt: string; seed: number; prefix: string }) => void
13
+ }
14
+ export type Graph = Record<string, { inputs: Record<string, unknown>; class_type: string }>
15
+ export type Workflow = { id: string; jsonPath: string; graph: Graph; sidekick: Sidekick }
16
+
17
+ // function declaration, not const — TS only narrows on never-calls to
18
+ // directly-declared functions
19
+ function fail(msg: string): never { throw new Error(msg) }
20
+
21
+ /** dir = the folder holding the <id>.json + <id>.ts pairs */
22
+ export async function loadWorkflows(dir: string): Promise<Map<string, Workflow>> {
23
+ const out = new Map<string, Workflow>()
24
+ if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.json + <id>.ts workflow pair`)
25
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
26
+ const id = basename(f, '.json')
27
+ const jsonPath = join(dir, f)
28
+ const tsPath = join(dir, `${id}.ts`)
29
+ if (!existsSync(tsPath)) fail(`workflow '${id}': ${f} has no sidekick ${id}.ts — every graph json ships with its adjacent .ts module`)
30
+ const graph = (await Bun.file(jsonPath).json()) as Graph
31
+ // ?rvload = a DISTINCT module instance: when a self-executing sidekick
32
+ // is the running script, importing its bare path would await its own
33
+ // in-flight top-level evaluation — a deadlock. NOTE the busted
34
+ // instance STILL sees import.meta.main=true (bun compares paths sans
35
+ // query), so its CLI tail runs too — the cliRunning guard in the
36
+ // sidekick runner is what keeps it inert. Never remove that guard.
37
+ const mod = (await import(`${tsPath}?rvload`)) as Partial<Sidekick>
38
+ const b = mod.bindings
39
+ if (b == null || !b.prompt?.node || !b.seed?.node || !b.prefix?.node) {
40
+ fail(`workflow '${id}': sidekick must export bindings = { prompt|seed|prefix: { node, input } }`)
41
+ }
42
+ for (const [role, bind] of Object.entries(b!) as [string, Binding][]) {
43
+ const n = graph[bind.node]
44
+ if (n == null) fail(`workflow '${id}': bindings.${role} points at node '${bind.node}' which is not in ${f} — graph re-exported with renumbered nodes? update the sidekick`)
45
+ if (!(bind.input in n!.inputs)) fail(`workflow '${id}': node '${bind.node}' (${n!.class_type}) has no input '${bind.input}' (has: ${Object.keys(n!.inputs).join(', ')})`)
46
+ }
47
+ for (const [node, cls] of Object.entries(mod.expects ?? {})) {
48
+ const n = graph[node]
49
+ if (n == null) fail(`workflow '${id}': expects node '${node}' missing from ${f}`)
50
+ if (n.class_type !== cls) fail(`workflow '${id}': node '${node}' is ${n.class_type}, sidekick expects ${cls} — graph changed under the sidekick, re-check bindings`)
51
+ }
52
+ out.set(id, { id, jsonPath, graph, sidekick: mod as Sidekick })
53
+ }
54
+ if (out.size === 0) fail(`${dir}/ has no <id>.json graphs`)
55
+ return out
56
+ }
57
+
58
+ /** Resolve a workflow: explicit id, else the sole loaded workflow. */
59
+ export function workflowFor(workflows: Map<string, Workflow>, ref: { workflow?: string; assetId?: string }): Workflow {
60
+ if (ref.workflow != null) {
61
+ const w = workflows.get(ref.workflow)
62
+ if (w == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
63
+ return w!
64
+ }
65
+ if (workflows.size === 1) return [...workflows.values()][0]!
66
+ return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
67
+ }
@@ -0,0 +1,30 @@
1
+ // Shared image utils for pick-ship hooks (sharp — the PIL replacement).
2
+ // Ship hooks import these instead of forking convert/flip code.
3
+ import sharp from 'sharp'
4
+
5
+ /** Convert any roll (webp/png) to PNG at dest; optional horizontal mirror.
6
+ * Flipped assets ship MIRRORED PIXELS — downstream metadata (rects…) must
7
+ * mirror coordinates itself and drop the flag so loaders never double-flip. */
8
+ export async function toPng(src: string, dest: string, opts?: { flipX?: boolean }): Promise<void> {
9
+ let img = sharp(src)
10
+ if (opts?.flipX === true) img = img.flop()
11
+ await img.png().toFile(dest)
12
+ }
13
+
14
+ /** Alpha-content height in px (alpha > threshold — AI cutouts leave low-alpha
15
+ * noise). Used to normalize prop sizes by visible art height. */
16
+ export async function alphaHeight(src: string, threshold = 24): Promise<number> {
17
+ const { data, info } = await sharp(src).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
18
+ let top = -1
19
+ let bottom = -1
20
+ for (let y = 0; y < info.height; y++) {
21
+ for (let x = 0; x < info.width; x++) {
22
+ if (data[(y * info.width + x) * info.channels + 3]! > threshold) {
23
+ if (top === -1) top = y
24
+ bottom = y
25
+ break
26
+ }
27
+ }
28
+ }
29
+ return top === -1 ? info.height : bottom - top + 1
30
+ }
@@ -0,0 +1,27 @@
1
+ // Locked JSON read-modify-write — the ONE way any tool (studio, fill CLI,
2
+ // a hand-rolled project script) writes workspace JSON files. Re-reads inside
3
+ // the lock so concurrent hand edits are never clobbered by a stale in-memory
4
+ // copy; never cache-and-write a whole file from outside.
5
+ import { existsSync } from 'fs'
6
+
7
+ const locks = new Map<string, Promise<void>>()
8
+
9
+ export async function updateJson<T>(path: string, init: T, updater: (data: T) => void | Promise<void>): Promise<T> {
10
+ while (locks.has(path)) await locks.get(path)
11
+ let release!: () => void
12
+ locks.set(path, new Promise<void>((r) => (release = r)))
13
+ try {
14
+ const data = existsSync(path) ? ((await Bun.file(path).json()) as T) : structuredClone(init)
15
+ await updater(data)
16
+ await Bun.write(path, JSON.stringify(data, null, 2) + '\n')
17
+ return data
18
+ } finally {
19
+ locks.delete(path)
20
+ release()
21
+ }
22
+ }
23
+
24
+ export async function readJson<T>(path: string, fallback: T): Promise<T> {
25
+ if (!existsSync(path)) return fallback
26
+ return (await Bun.file(path).json()) as T
27
+ }