rvlib-shipkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +23 -8
  3. package/package.json +29 -13
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +161 -0
  11. package/src/assets/kits/identity-kit.ts +80 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +122 -0
  16. package/src/assets/server/studio-page.ts +587 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +360 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Tui.ts +36 -0
  27. package/src/cli/cmd_Wiki.ts +53 -0
  28. package/src/cli.ts +42 -25
  29. package/src/contracts/run-contracts.ts +33 -0
  30. package/src/core/check-ctx.ts +3 -16
  31. package/src/core/check-suite.ts +16 -2
  32. package/src/core/discover-checks.ts +48 -0
  33. package/src/core/run-check.ts +73 -0
  34. package/src/core/run-ci.ts +54 -0
  35. package/src/core/spawn-timeout.ts +65 -0
  36. package/src/core/task-runner.ts +36 -43
  37. package/src/core/wire.ts +20 -0
  38. package/src/core/write-status.ts +70 -0
  39. package/src/init-repo.ts +60 -0
  40. package/src/repo-config.ts +68 -16
  41. package/src/shipkit.ts +34 -3
  42. package/src/tui/checks-panel.ts +39 -0
  43. package/src/tui/tasks-panel.ts +54 -0
  44. package/src/tui/tui-app.tsx +91 -0
  45. package/src/tui/tui-panel.ts +57 -0
  46. package/src/tui/tui-store.ts +98 -0
  47. package/src/wiki/run-wiki.ts +50 -0
  48. package/src/cli/shipkit-asks.ts +0 -26
  49. package/src/cli/shipkit-check.ts +0 -26
  50. package/src/cli/shipkit-ci.ts +0 -30
  51. package/src/cli/shipkit-contracts.ts +0 -26
  52. package/src/cli/shipkit-init.ts +0 -40
  53. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,161 @@
1
+ // defineKit — the repo-side surface: .rv-assets/kit.ts declares asset kinds,
2
+ // generators, ship targets and metadata panels; the studio/CLI derive
3
+ // everything else. STRUCTURE lives here (git-reviewed TS); studio-edited
4
+ // state lives in the workspace overlays (pick.json, prompt.json, *.rect.json).
5
+ // Kit edits need a studio restart; prompt tweaks don't (overlay).
6
+ import { existsSync } from 'fs'
7
+ import { join } from 'path'
8
+
9
+ export type AssetSpec = {
10
+ id: string
11
+ /** FULL prompt text incl. style — the studio shows/edits exactly what generates */
12
+ prompt: string
13
+ label?: string
14
+ /** freeform data passed through to the ship hook */
15
+ ship?: unknown
16
+ }
17
+
18
+ export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
19
+ export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
20
+ export type GeneratorSpec = ComfyGen | CanvasGen
21
+ export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
22
+ export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
23
+
24
+ export type GeometryPanelSpec = {
25
+ panel: 'geometry'
26
+ /** oval height = width × ratio, ratio OWNED by the repo (mirror of its game constant) */
27
+ ratio: number
28
+ /** default oval when an asset has no saved rect yet */
29
+ defaultOval: { x: number; y: number; w: number; size: number }
30
+ /** per-category default size overrides */
31
+ sizeByCategory?: Record<string, number>
32
+ legs?: boolean
33
+ }
34
+ export type GridPanelSpec = { panel: 'grid'; fields: ('size' | 'flip')[] }
35
+ /** Repo-defined metadata editor: an ES module the studio serves at
36
+ * GET /panel/<kindId>/<id>.js and mounts per asset —
37
+ * `export function mount(el, api)` (+ optional `unmount()`), api =
38
+ * { assetId, kindId, roll, imgUrl, getMeta(), saveMeta(json) } backed by
39
+ * the roll's meta sidecar. Geometry/grid stay baked-in; this is the
40
+ * escape hatch for ANY other metadata shape. */
41
+ export type CustomPanelSpec = {
42
+ panel: 'custom'
43
+ /** url-safe kebab-case, unique per kind — becomes the served module name */
44
+ id: string
45
+ /** repo-root-relative ES module path (e.g. .rv-assets/panels/legs-editor.js) */
46
+ script: string
47
+ }
48
+ export type PanelSpec = GeometryPanelSpec | GridPanelSpec | CustomPanelSpec
49
+ export const geometryPanel = (p: Omit<GeometryPanelSpec, 'panel'>): GeometryPanelSpec => ({ panel: 'geometry', ...p })
50
+ export const gridPanel = (p: Omit<GridPanelSpec, 'panel'>): GridPanelSpec => ({ panel: 'grid', ...p })
51
+ export const customPanel = (p: Omit<CustomPanelSpec, 'panel'>): CustomPanelSpec => ({ panel: 'custom', ...p })
52
+
53
+ export type KindMeta = {
54
+ /** live write-through target, repo-root-relative (e.g. assets/heroes/ground-rects.json) */
55
+ file?: string
56
+ panels?: PanelSpec[]
57
+ /** ships to an art-normalized folder: sizes are in art-height units */
58
+ artNormalized?: boolean
59
+ /** repo-owned semantics gate, run before any metadata write-through —
60
+ * return error strings to reject the write (loud, never silent) */
61
+ validate?: (data: unknown) => string[] | void
62
+ }
63
+
64
+ export type Kind = {
65
+ id: string
66
+ category: string
67
+ assets: AssetSpec[]
68
+ generate: GeneratorSpec
69
+ /** icon kinds get no geometry, thumbnails only */
70
+ mode?: 'sprite' | 'icon'
71
+ ship?: { dir?: string; via?: string }
72
+ meta?: KindMeta
73
+ }
74
+
75
+ export type KitConfig = {
76
+ port?: number
77
+ /** roll workspace root, repo-root-relative; gitignored */
78
+ workspace?: string
79
+ comfy?: { host: string; workflows: string; heal?: string }
80
+ kinds: Kind[]
81
+ }
82
+
83
+ export type ResolvedKit = KitConfig & { port: number; workspace: string }
84
+
85
+ export function validateKit(k: KitConfig): string[] {
86
+ const errs: string[] = []
87
+ if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
88
+ const kindIds = new Set<string>()
89
+ const assetIds = new Set<string>()
90
+ for (const kind of k.kinds ?? []) {
91
+ if (!kind.id || !kind.category) errs.push(`kind needs id + category (got ${JSON.stringify({ id: kind.id, category: kind.category })})`)
92
+ if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
93
+ kindIds.add(kind.id)
94
+ if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
95
+ if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
96
+ for (const a of kind.assets ?? []) {
97
+ if (!a.id || typeof a.prompt !== 'string') errs.push(`kind '${kind.id}': asset needs id + prompt (got ${JSON.stringify(a).slice(0, 80)})`)
98
+ if (assetIds.has(a.id)) errs.push(`duplicate asset id '${a.id}' (ids are global across kinds)`)
99
+ assetIds.add(a.id)
100
+ }
101
+ const panelIds = new Set<string>()
102
+ for (const p of kind.meta?.panels ?? []) {
103
+ if (p.panel !== 'custom') continue
104
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(p.id ?? '')) errs.push(`kind '${kind.id}': custom panel id must be url-safe kebab-case (got '${p.id}')`)
105
+ if (panelIds.has(p.id)) errs.push(`kind '${kind.id}': duplicate custom panel id '${p.id}'`)
106
+ panelIds.add(p.id)
107
+ if (typeof p.script !== 'string' || p.script === '') errs.push(`kind '${kind.id}' panel '${p.id}': script path required (repo-root-relative ES module)`)
108
+ else if (p.script.startsWith('/') || p.script.split('/').includes('..'))
109
+ errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
110
+ }
111
+ }
112
+ return errs
113
+ }
114
+
115
+ /** Custom panel scripts that don't exist on disk — checked at kit load AND
116
+ * studio boot so a typo'd path dies loud, never as a silent 404 in the SPA. */
117
+ export function missingPanelScripts(kit: KitConfig, root: string): string[] {
118
+ const missing: string[] = []
119
+ for (const kind of kit.kinds) {
120
+ for (const p of kind.meta?.panels ?? []) {
121
+ if (p.panel !== 'custom') continue
122
+ if (!existsSync(join(root, p.script))) missing.push(`kind '${kind.id}' panel '${p.id}': ${p.script} not found under ${root}`)
123
+ }
124
+ }
125
+ return missing
126
+ }
127
+
128
+ export function defineKit(k: KitConfig): ResolvedKit {
129
+ const errs = validateKit(k)
130
+ if (errs.length > 0) throw new Error(`invalid kit:\n ${errs.join('\n ')}`)
131
+ return { ...k, port: k.port ?? 4800, workspace: k.workspace ?? '.rv-assets/out' }
132
+ }
133
+
134
+ export const defineKind = (k: Kind): Kind => k
135
+
136
+ export async function loadKit(root: string): Promise<ResolvedKit> {
137
+ const path = join(root, '.rv-assets/kit.ts')
138
+ if (!existsSync(path)) throw new Error(`no .rv-assets/kit.ts in ${root} — create one (defineKit) or run the init snippet from the docs`)
139
+ const mod = (await import(path)) as { default?: ResolvedKit }
140
+ if (mod.default == null) throw new Error(`${path}: must default-export defineKit({...})`)
141
+ const missing = missingPanelScripts(mod.default, root)
142
+ if (missing.length > 0) throw new Error(`kit custom panel scripts missing — create them or fix the paths:\n ${missing.join('\n ')}`)
143
+ return mod.default
144
+ }
145
+
146
+ // ── lookups shared by server + CLI ──────────────────────────────────────────
147
+ export type FoundAsset = { kind: Kind; asset: AssetSpec }
148
+
149
+ export function findAsset(kit: KitConfig, assetId: string): FoundAsset | null {
150
+ for (const kind of kit.kinds) {
151
+ const asset = kind.assets.find((a) => a.id === assetId)
152
+ if (asset != null) return { kind, asset }
153
+ }
154
+ return null
155
+ }
156
+
157
+ export function allAssets(kit: KitConfig): FoundAsset[] {
158
+ return kit.kinds.flatMap((kind) => kind.assets.map((asset) => ({ kind, asset })))
159
+ }
160
+
161
+ export const categoriesOf = (kit: KitConfig): string[] => [...new Set(kit.kinds.map((k) => k.category))]
@@ -0,0 +1,80 @@
1
+ // kits/identity — favicon / logo / OG as asset kinds. Two paths:
2
+ // - canvas mark: deterministic, instant — `renderIdentity` writes the whole
3
+ // set headlessly (the CLI `identity` command).
4
+ // - comfy mark: the kinds go through the normal roll-and-pick studio loop
5
+ // (a placeholder-grade logo auto-accepted is a non-goal).
6
+ // Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
7
+ // (1200×630), public/logo.png. Brand data is per-repo and never defaults
8
+ // across repos.
9
+ import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
10
+ import { mkdirSync } from 'fs'
11
+ import { dirname, join } from 'path'
12
+ import sharp from 'sharp'
13
+ import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
14
+
15
+ export type Brand = {
16
+ name: string
17
+ tagline?: string
18
+ /** background / accent / ink — used by the default og layout */
19
+ palette: { bg: string; accent: string; ink: string }
20
+ /** the mark: a canvas draw (deterministic) or a comfy generator (roll-and-pick) */
21
+ mark: CanvasGen | ComfyGen
22
+ /** og layout hook — default: bg fill, mark left, name+tagline right */
23
+ drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
24
+ }
25
+
26
+ export const FAVICON_SIZE = 512
27
+ export const OG_W = 1200
28
+ export const OG_H = 630
29
+ export const LOGO_SIZE = 1024
30
+
31
+ export function identityKinds(brand: Brand): Kind[] {
32
+ return [
33
+ defineKind({
34
+ id: 'identity-logo', category: 'Identity', mode: 'icon',
35
+ assets: [{ id: 'identity-logo', prompt: `logo mark for ${brand.name}`, label: 'logo' }],
36
+ generate: brand.mark,
37
+ }),
38
+ ]
39
+ }
40
+
41
+ const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }): void => {
42
+ ctx.fillStyle = brand.palette.bg
43
+ ctx.fillRect(0, 0, size.w, size.h)
44
+ ctx.fillStyle = brand.palette.accent
45
+ ctx.fillRect(0, size.h - 14, size.w, 14)
46
+ ctx.fillStyle = brand.palette.ink
47
+ ctx.font = `bold ${Math.round(size.h / 6)}px sans-serif`
48
+ ctx.textBaseline = 'middle'
49
+ ctx.fillText(brand.name, 64, size.h / 2 - (brand.tagline != null ? size.h / 12 : 0))
50
+ if (brand.tagline != null) {
51
+ ctx.font = `${Math.round(size.h / 14)}px sans-serif`
52
+ ctx.fillStyle = brand.palette.accent
53
+ ctx.fillText(brand.tagline, 66, size.h / 2 + size.h / 9)
54
+ }
55
+ }
56
+
57
+ /** Headless render of the full identity set — canvas marks only (a comfy
58
+ * mark goes through the studio; this errors loud instead of guessing). */
59
+ export async function renderIdentity(brand: Brand, root: string): Promise<string[]> {
60
+ if (brand.mark.backend !== 'canvas') throw new Error(`brand '${brand.name}' uses a comfy mark — roll + pick it in the studio; 'identity' renders canvas marks only`)
61
+ const out: string[] = []
62
+ const write = async (rel: string, buf: Buffer): Promise<void> => {
63
+ const path = join(root, rel)
64
+ mkdirSync(dirname(path), { recursive: true })
65
+ await Bun.write(path, buf)
66
+ out.push(rel)
67
+ }
68
+ // logo master — the mark draws into a square canvas
69
+ const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
70
+ await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
71
+ const logoPng = logo.toBuffer('image/png')
72
+ await write('public/logo.png', logoPng)
73
+ // favicon = logo master resized to 512² (browsers derive the rest)
74
+ await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
75
+ // og card
76
+ const og = createCanvas(OG_W, OG_H)
77
+ ;(brand.drawOg ?? defaultOg)(og.getContext('2d'), brand, { w: OG_W, h: OG_H })
78
+ await write('public/og.png', og.toBuffer('image/png'))
79
+ return out
80
+ }
@@ -0,0 +1,18 @@
1
+ // Native-dep readiness probes for the asset generators. These MUST live in the
2
+ // lib so the `@napi-rs/canvas` / `sharp` specifiers resolve against the lib's
3
+ // node_modules (the same way the generators do) — a repo probing from its own
4
+ // root would false-negative. Each not only resolves the module but exercises
5
+ // the native binding (a present-but-ABI-broken .node passes resolution yet
6
+ // dies at first generate — the exact A32 gap). Both return the package name;
7
+ // both throw loud on failure.
8
+ export async function assertCanvasReady(): Promise<string> {
9
+ const { createCanvas } = await import('@napi-rs/canvas')
10
+ createCanvas(2, 2).getContext('2d').fillRect(0, 0, 1, 1)
11
+ return '@napi-rs/canvas'
12
+ }
13
+
14
+ export async function assertSharpReady(): Promise<string> {
15
+ const sharp = (await import('sharp')).default
16
+ await sharp({ create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).png().toBuffer()
17
+ return 'sharp'
18
+ }
@@ -0,0 +1,84 @@
1
+ // Ground-geometry rect math: the session↔live formats behind the geometry
2
+ // panel. SEMANTICS (what the oval means, size units, the ratio value) are
3
+ // the consuming repo's; this module owns only the mechanics:
4
+ // - session rect (per roll, <workspace>/studio/<id>/<roll>.rect.json):
5
+ // { x,y,w,h,size,art?,flip?,legs?[4],legColor? } — h ALWAYS re-derived
6
+ // = w × ratio preserving center-y, so data can never drift off-ratio.
7
+ // - live rect (the game file): flip folds into MIRRORED coordinates and the
8
+ // flag is DROPPED — pixels ship mirrored, loaders must never double-flip.
9
+ export type LegRect = { x: number; y: number; w: number; h: number }
10
+ export type GroundRect = {
11
+ x: number; y: number; w: number; h: number
12
+ size: number
13
+ /** alpha-content height px (studio metadata; props normalize by it) */
14
+ art?: number
15
+ flip?: boolean
16
+ legs?: LegRect[]
17
+ legColor?: string
18
+ }
19
+
20
+ const r2 = (n: number): number => Math.round(n * 100) / 100
21
+ const r3 = (n: number): number => Math.round(n * 1000) / 1000
22
+
23
+ /** Normalize a possibly-legacy rect: fold `scale` into `size`, round
24
+ * (x/y/w/h 2dp, size 3dp, legs 2dp), ratio-lock h preserving center-y. */
25
+ export function cleanRect(raw: Record<string, unknown>, ratio: number): GroundRect {
26
+ const num = (k: string, dflt: number): number => (typeof raw[k] === 'number' && Number.isFinite(raw[k]) ? (raw[k] as number) : dflt)
27
+ const size = typeof raw.size === 'number' ? (raw.size as number) : num('scale', 1) // legacy key folds in
28
+ const x = num('x', 0)
29
+ const y = num('y', 0)
30
+ const w = Math.max(1, num('w', 1))
31
+ const oldH = num('h', w * ratio)
32
+ const h = w * ratio
33
+ const yLocked = y + (oldH - h) / 2 // keep center-y while re-locking the ratio
34
+ const out: GroundRect = { x: r2(x), y: r2(yLocked), w: r2(w), h: r2(h), size: r3(size) }
35
+ if (typeof raw.art === 'number') out.art = raw.art as number
36
+ if (raw.flip === true) out.flip = true
37
+ if (Array.isArray(raw.legs) && raw.legs.length === 4) {
38
+ out.legs = (raw.legs as LegRect[]).map((l) => ({ x: r2(l.x), y: r2(l.y), w: r2(l.w), h: r2(l.h) }))
39
+ if (typeof raw.legColor === 'string') out.legColor = raw.legColor
40
+ }
41
+ return out
42
+ }
43
+
44
+ /** Session → live: mirror x when flipped (oval AND legs), DROP the flag. */
45
+ export function liveRect(clean: GroundRect, textureWidth = 1024): GroundRect {
46
+ if (clean.flip !== true) {
47
+ const { flip: _flip, ...rest } = clean
48
+ return { ...rest }
49
+ }
50
+ const { flip: _flip, ...rest } = clean
51
+ const out: GroundRect = { ...rest, x: r2(textureWidth - clean.x - clean.w) }
52
+ if (clean.legs != null) out.legs = clean.legs.map((l) => ({ ...l, x: r2(textureWidth - l.x - l.w) }))
53
+ return out
54
+ }
55
+
56
+ /** Default 4-leg layout derived from the oval — order [L·front, L·back,
57
+ * R·back, R·front]; pair (0,1) = far side, (2,3) = near side. Each rect IS
58
+ * the leg: top-center = hip, bottom-center = foot on the ground. */
59
+ export function defaultLegs(oval: GroundRect): LegRect[] {
60
+ const u = oval.w
61
+ const cx = oval.x + oval.w / 2
62
+ const cy = oval.y + oval.h / 2
63
+ const lw = 0.09 * u
64
+ const lh = 0.3 * u
65
+ const centersDx = [0.2, -0.3, -0.22, 0.28]
66
+ return centersDx.map((dx, i) => {
67
+ const footY = cy + (i < 2 ? -0.06 : 0.06) * u
68
+ return { x: r2(cx + dx * u - lw / 2), y: r2(footY - lh), w: r2(lw), h: r2(lh) }
69
+ })
70
+ }
71
+
72
+ /** Legacy circle → oval migration (run on session read). A circle is
73
+ * |h − w| ≤ 0.5. anchor: where the new oval's center-y comes from —
74
+ * 'bottom' (characters/scenery: feet at circle bottom) or 'center'
75
+ * (buildings). artNormalized folds art-height units into oval-width units:
76
+ * size *= w / art. Non-circles pass through untouched. */
77
+ export function migrateCircle(rect: GroundRect, opts: { ratio: number; anchor: 'bottom' | 'center'; artNormalized?: boolean }): GroundRect {
78
+ if (Math.abs(rect.h - rect.w) > 0.5) return rect
79
+ const cy = opts.anchor === 'bottom' ? rect.y + rect.h : rect.y + rect.h / 2
80
+ let size = rect.size
81
+ if (opts.artNormalized === true && rect.art != null && rect.art > 0) size = size * (rect.w / rect.art)
82
+ const h = rect.w * opts.ratio
83
+ return cleanRect({ ...rect, size, h, y: cy - h / 2 } as unknown as Record<string, unknown>, opts.ratio)
84
+ }
@@ -0,0 +1,41 @@
1
+ // The <workspace>/studio/<assetId>/ roll-workspace conventions — shared by
2
+ // the studio server, the fill CLI and ship hooks. Files per asset dir:
3
+ // v<seed>.webp|png rolls · pick.json { pick, alts[], shippedAt } ·
4
+ // <roll>.meta.json freeform sidecars (project tooling reads/writes them) ·
5
+ // prompt.json { prompt } overlay layered over the kind's prompt builder.
6
+ // All writes go through the locked json-store; hand edits are legal (the
7
+ // studio re-reads every poll).
8
+ import { existsSync, readdirSync, statSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { readJson, updateJson } from './json-store.ts'
11
+
12
+ export type PickState = { pick: string | null; alts: string[]; shippedAt: string | null }
13
+ export const PICK_INIT: PickState = { pick: null, alts: [], shippedAt: null }
14
+
15
+ export type PromptOverlay = { prompt: string | null }
16
+ export const PROMPT_INIT: PromptOverlay = { prompt: null }
17
+
18
+ export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png)$/.test(f)
19
+
20
+ export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
21
+
22
+ export function listRolls(dir: string): string[] {
23
+ if (!existsSync(dir)) return []
24
+ return readdirSync(dir)
25
+ .filter(isRollFile)
26
+ .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs) // newest first
27
+ }
28
+
29
+ export const pickPath = (dir: string): string => join(dir, 'pick.json')
30
+
31
+ export const readPick = (dir: string): Promise<PickState> => readJson(pickPath(dir), PICK_INIT)
32
+
33
+ export const updatePick = (dir: string, up: (s: PickState) => void): Promise<PickState> => updateJson(pickPath(dir), PICK_INIT, up)
34
+
35
+ export const metaPath = (dir: string, roll: string): string => join(dir, `${roll}.meta.json`)
36
+
37
+ export const promptPath = (dir: string): string => join(dir, 'prompt.json')
38
+
39
+ export const readPromptOverlay = (dir: string): Promise<PromptOverlay> => readJson(promptPath(dir), PROMPT_INIT)
40
+
41
+ export const updatePromptOverlay = (dir: string, up: (s: PromptOverlay) => void): Promise<PromptOverlay> => updateJson(promptPath(dir), PROMPT_INIT, up)
@@ -0,0 +1,122 @@
1
+ // shipkit assets — implementations behind cli/cmd_Assets.ts (clipanion owns
2
+ // parsing; these plain functions own the logic, print through injected
3
+ // sinks, and return exit codes — 0 ok · 1 fail).
4
+ import { existsSync } from 'fs'
5
+ import { join } from 'path'
6
+ import { randomSeed } from './gen/comfy-gen.ts'
7
+ import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
8
+ import { loadWorkflows } from './gen/workflow-loader.ts'
9
+ import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
10
+ import { renderIdentity, type Brand } from './kits/identity-kit.ts'
11
+ import { assetDir, listRolls, readPromptOverlay } from './roll-workspace.ts'
12
+ import { page } from './server/studio-page.ts'
13
+ import { serveStudio } from './server/studio-server.ts'
14
+
15
+ export type Print = (line: string) => void
16
+ export type AssetsIo = { print: Print; printErr: Print }
17
+
18
+ class AssetsFail extends Error {}
19
+ const die = (msg: string): never => { throw new AssetsFail(msg) }
20
+
21
+ // Lazy comfy runtime — a canvas-only kit (no comfy: block) never loads
22
+ // workflows or needs the box; only a real comfy roll triggers resolution.
23
+ const comfyFor = (root: string, kit: ResolvedKit): ComfyResolver =>
24
+ comfyResolver(root, kit, () => die('this kit has no comfy config — add comfy: { host, workflows } to fill comfy assets'))
25
+
26
+ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveComfy: ComfyResolver, assetId: string, count: number): Promise<number> {
27
+ const f = findAsset(kit, assetId)
28
+ if (f == null) die(`unknown asset '${assetId}'`)
29
+ const dir = assetDir(join(root, kit.workspace), assetId)
30
+ const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
31
+ let failed = 0
32
+ for (let i = 0; i < count; i++) {
33
+ const seed = randomSeed()
34
+ try {
35
+ await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
36
+ io.print(`ok: ${assetId} v${seed}`)
37
+ } catch (e) {
38
+ failed++
39
+ io.printErr(`FAIL: ${assetId} seed ${seed}: ${(e as Error).message}`)
40
+ }
41
+ }
42
+ return failed
43
+ }
44
+
45
+ // wraps an implementation so kit/config errors become one FAIL line + exit 1
46
+ async function guarded(io: AssetsIo, body: () => Promise<number>): Promise<number> {
47
+ try {
48
+ return await body()
49
+ } catch (e) {
50
+ if (e instanceof AssetsFail) { io.printErr(`FAIL: ${e.message}`); return 1 }
51
+ throw e
52
+ }
53
+ }
54
+
55
+ export const runAssetsServe = (root: string, io: AssetsIo): Promise<number> =>
56
+ guarded(io, async () => {
57
+ const kit = await loadKit(root)
58
+ const studio = await serveStudio({ kit, root, page })
59
+ io.print(`ok: studio → http://127.0.0.1:${studio.server.port} (${allAssets(kit).length} assets)`)
60
+ await new Promise(() => {}) // Bun.serve owns the process — never resolve
61
+ return 0
62
+ })
63
+
64
+ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | 'add'; n: number; categories: string[] }): Promise<number> =>
65
+ guarded(io, async () => {
66
+ const kit = await loadKit(root)
67
+ const resolveComfy = comfyFor(root, kit)
68
+ // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process)
69
+ const targets = allAssets(kit)
70
+ .filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
71
+ .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
72
+ .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
73
+ .filter((t) => t.want > 0)
74
+ .sort((a, b) => a.have - b.have) // sparsest first
75
+ let failed = 0
76
+ for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
77
+ io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
78
+ return failed > 0 ? 1 : 0
79
+ })
80
+
81
+ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n: number }): Promise<number> =>
82
+ guarded(io, async () => {
83
+ const kit = await loadKit(root)
84
+ const failed = await rollOnce(root, io, kit, comfyFor(root, kit), o.assetId, o.n)
85
+ return failed > 0 ? 1 : 0
86
+ })
87
+
88
+ export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
89
+ guarded(io, async () => {
90
+ const brandPath = join(root, '.rv-identity/brand.ts')
91
+ if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
92
+ const mod = (await import(brandPath)) as { default?: Brand }
93
+ if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
94
+ const written = await renderIdentity(mod.default!, root)
95
+ for (const w of written) io.print(`ok: ${w}`)
96
+ return 0
97
+ })
98
+
99
+ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
100
+ guarded(io, async () => {
101
+ let bad = 0
102
+ const kit = await loadKit(root).catch((e: Error) => { io.print(`FAIL: ${e.message}`); bad++; return null })
103
+ if (kit == null) return 1
104
+ io.print(`ok: kit loads — ${kit.kinds.length} kind(s), ${allAssets(kit).length} asset(s), workspace ${kit.workspace}`)
105
+ if (kit.comfy != null) {
106
+ try {
107
+ const wfs = await loadWorkflows(join(root, kit.comfy.workflows))
108
+ io.print(`ok: ${wfs.size} workflow pair(s) bound (${[...wfs.keys()].join(', ')})`)
109
+ } catch (e) {
110
+ io.print(`FAIL: ${(e as Error).message}`)
111
+ bad++
112
+ }
113
+ const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
114
+ io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
115
+ }
116
+ if (existsSync(join(root, '.rv-identity/brand.ts'))) {
117
+ const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
118
+ if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
119
+ else io.print('ok: identity outputs present')
120
+ }
121
+ return bad > 0 ? 1 : 0
122
+ })