rvlib-shipkit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +23 -8
- package/package.json +15 -3
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +161 -0
- package/src/assets/kits/identity-kit.ts +80 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +122 -0
- package/src/assets/server/studio-page.ts +587 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +360 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +42 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +68 -16
- package/src/shipkit.ts +34 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -41
- package/src/cli/shipkit-wiki.ts +0 -74
package/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,
|
|
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: '
|
|
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:
|
|
28
|
-
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
}
|
package/src/asks/asks-queue.ts
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
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
|
+
}
|