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.
- package/CHANGELOG.md +137 -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 +179 -0
- package/src/assets/kits/identity-kit.ts +119 -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 +142 -0
- package/src/assets/server/studio-page.ts +620 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +412 -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_Serve.ts +34 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +44 -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 +82 -15
- package/src/serve/hub-pages.ts +84 -0
- package/src/serve/run-serve.ts +85 -0
- package/src/serve/serve-hub.ts +77 -0
- package/src/shipkit.ts +39 -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,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,
|
|
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.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
|
}
|
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
|
+
}
|