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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,142 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-07-22
4
+
5
+ New:
6
+
7
+ - identity from a PICKED comfy mark (W2-1): `renderIdentityFromMark` is THE
8
+ identity writer (mark png → logo 1024² master → favicon 512² → og card
9
+ compositing bg + mark left + name/tagline right, fonts shrink-to-fit);
10
+ canvas marks and picked comfy rolls both feed it — one render path.
11
+ `identityKinds(brand)` now carries a BUILT-IN ship hook (`Kind.ship.fn`,
12
+ new — a lib-side ShipFn; `ship.fn` + `ship.via` on one kind is a kit
13
+ error), so picking a logo roll in the studio re-renders the whole set
14
+ automatically. `shipkit assets identity` with a comfy mark renders from
15
+ the current pick and FAILs loud with the next step when nothing is picked
16
+ ("roll + pick in the studio: bun run assets → #identity-logo") — never a
17
+ placeholder. `Brand.markPrompt` = the full roll prompt for the mark.
18
+ Exports: `renderIdentityFromMark`, `NO_PICK_FIX`, `ShipContext`, `ShipFn`.
19
+
20
+ - `shipkit serve` — ONE hub server per repo, ONE port: a no-build landing
21
+ page whose links are the whole toolbox. Routes: `/graph/wiki` (md link
22
+ graph — repo.config `serve.wikiGraph` plugs a custom provider, generic
23
+ scan otherwise) · `/graph/agent` (when `agent/` exists; `serve.agentIndex`
24
+ picks the entrypoint) · `/assets/` (the studio, mounted IN-PROCESS on the
25
+ same port) · `/status` (STATUS.md rendered + a "run check" button that
26
+ shells `shipkit check` and streams the wire lines). The same URLs print
27
+ to stdout as clickable `ok:` lines — the intended wiring is ONE vscode
28
+ folder-open task. Port: `serve.port`, else hashed from the repo name into
29
+ 4800-4979; a taken port is probed via `/hub.json` — the same repo in
30
+ another window exits 0, a DIFFERENT repo fails loud with a fix pointer.
31
+ - studio routes are mountable: `studioRoutes(deps)` returns the route table
32
+ (`handle(req, pathname)`); `serveStudio` (standalone `shipkit assets
33
+ serve`, own port) and the hub's `/assets/` mount share it — one route
34
+ table, two front doors. The studio page now resolves its urls RELATIVE to
35
+ the page (works at `/` and under `/assets/`); custom panel module urls
36
+ are `./panel/…` accordingly.
37
+ - repo.config gains `serve?: { port?, wikiGraph?, agentIndex? }`
38
+ (`ServeConfig`).
39
+ - studio per-asset info surface (W2-2): the detail view shows where every
40
+ roll comes from and where a pick goes — backend, resolved workflow id +
41
+ its `.rv-comfy/` path (or WHY it's unresolved), ship target (built-in /
42
+ hook / record), meta write-through file, kit source. `/api/asset` gains
43
+ an additive `info` block. Deterministic canvas kinds (draw takes no seed
44
+ → identical rolls) are labelled next to Generate and capped to one roll
45
+ instead of pretending to be a slot machine.
46
+ - `shipkit assets check` prints workflow provenance (W2-3): one `ok:` line
47
+ per bound pair with its repo-relative `.{json,ts}` path — there is no
48
+ global workflow store, git owns each repo's pairs; the sharing doctrine
49
+ lives in build/comfyui.md.
50
+ - a second `shipkit serve` launch on an already-owned port now prints the
51
+ full clickable tool URLs (from the owner's `/hub.json`), not just the hub
52
+ root — a re-run terminal is as clickable as a first run.
53
+
54
+ Fixed:
55
+
56
+ - `/status` page: STATUS.md content is `<`-escaped inside the inline
57
+ script — a literal `</script>` in a check line can no longer break the
58
+ page.
59
+
60
+ Breaking: none (0.2.0 consumers upgrade clean; `Kind.ship.fn` and `serve`
61
+ config are additive).
62
+
63
+ ## 0.2.0 — 2026-07-22
64
+
65
+ The one-runner release: shipkit becomes the all-in-one companion
66
+ binary/cli/tui of every repo — ci gate, health checks, contracts, asks,
67
+ wiki graphs, asset atelier, live dashboard, init.
68
+
69
+ BREAKING:
70
+
71
+ - repo.config `checks:[]` + the `RepoCheck` type are DELETED (zero
72
+ consumers existed) — drop a `<name>.check.ts` file anywhere instead;
73
+ `shipkit check` discovers it.
74
+ - `defineRepo` now REJECTS unknown top-level and `defaults` keys — a typo
75
+ can no longer silently thin the gate.
76
+ - the standalone `rvlib-atelier` npm package is SUPERSEDED by this release
77
+ (its source folded in as `src/assets/`); it stays published as the frozen
78
+ predecessor, no new releases, deprecated after its last consumer swaps.
79
+
80
+ New:
81
+
82
+ - assets: `customPanel({ id, script })` — repo-defined metadata editors in
83
+ the studio. `script` is a repo-root-relative ES module served at
84
+ `GET /panel/<kindId>/<panelId>.js` and mounted as a drawer tab per asset:
85
+ `mount(el, api)` (+ optional `unmount()`), api =
86
+ `{ assetId, kindId, roll, imgUrl, getMeta(), saveMeta(json) }` backed by
87
+ the roll's meta sidecar (`/api/meta`, single writer). Script paths are
88
+ validated at kit load AND studio boot (loud error, never a silent 404);
89
+ panel ids are url-safe kebab-case, no `..`/absolute paths. geometry/grid
90
+ panels stay baked in — this is the escape hatch for any other shape.
91
+
92
+ - tui: `shipkit tui` — ONE live dashboard for every repo. Built-in panels:
93
+ Tasks (the ci gate streamed through the DAG runner via the new
94
+ `onTaskFinish` tap; STATUS.md refreshes like a terminal `shipkit ci`) and
95
+ Checks (contracts + discovered `*.check.ts` via the new CheckSuite
96
+ `onResult` tap — cached replays flagged, TTL layers intact). Panels are
97
+ render-free data (`TuiPanelDef`: rows + wire-format detail lines); repos
98
+ append their own via repo.config `tui: { panels: [...] }`. The view (ink +
99
+ mobx `makeAutoObservable`) normalizes wire lines to one visual language
100
+ (✓/✗/⚠/~/»). `asks.emptyText` config personalizes the `shipkit asks`
101
+ all-clear line.
102
+
103
+ - floor integrity (zero-config O6): with no package.json script,
104
+ `imports:check`/`cycles` fill from the bundled rvlib-ts-tools dep and
105
+ `typecheck` from the repo's tsc; a slot nothing can fill prints a
106
+ `skip: <id> (reason)` line — a thin gate is never silent. `defineRepo`
107
+ now REJECTS unknown top-level and `defaults` keys. `shipkit init` prints
108
+ how each floor slot resolved and gains `--bin <cli.ts path>` for
109
+ in-repo-source consumers (scripts become `bun <path> <cmd>`).
110
+ - STATUS.md: `writeStatus(root, …)` in the lib is THE writer — timestamp +
111
+ RED/GREEN verdict + fenced compact render, atomic (tmp + rename),
112
+ lock-guarded (concurrent runner skips with a WARN; stale locks stolen).
113
+ `shipkit ci` and `shipkit check` refresh it by default (`status: false`
114
+ opts out).
115
+ - check: `*.check.ts` auto-discovery is THE registration — `shipkit check`
116
+ scans the repo (skips node_modules/dist/tmp/references + dot-dirs) and
117
+ runs each file as a subprocess: wire output parsed back (`parseWireOutput`
118
+ exported), exit code is truth, non-zero exit with no FAIL line still fails
119
+ loud. Checks running their own CheckSuite are never double-cached; plain
120
+ checks get a 30m orchestrator TTL; `--fresh` busts every layer and travels
121
+ to children.
122
+ - core: ONE spawn-with-timeout (`spawnWithTimeout`, exported) — task runner
123
+ and `CheckCtx.exec` now share it (binary resolved before spawn, streamed
124
+ output, SIGTERM→SIGKILL escalation); duplicate implementations deleted.
125
+
126
+ - assets: the atelier folded in as the `src/assets/` surface — asset-kind
127
+ registry (defineKit/defineKind), ComfyUI + procedural-canvas generators,
128
+ roll-and-pick studio server, metadata editor panels, kits/identity
129
+ (favicon/logo/OG), `rvlib-shipkit/vite` save-endpoint factory. CLI:
130
+ `shipkit assets serve|fill|add|gen|identity|check`.
131
+ - CLI rebuilt on clipanion (one command per file, `Command.Usage` help with
132
+ categories + examples, testable contexts); wire output contract unchanged
133
+ and frozen by a CLI-level test. Every value-taking option carries a
134
+ description — per-command `--help` renders a clean synopsis + Options
135
+ table.
136
+ - deps: @napi-rs/canvas + sharp (assets), clipanion (CLI), ink + react +
137
+ mobx + mobx-react-lite (tui), rvlib-ts-tools (imports/cycles floor for
138
+ every repo).
139
+
3
140
  ## 0.1.1 — 2026-07-22
4
141
 
5
142
  - `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.3.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
+ }