rvlib-shipkit 0.2.0 → 0.3.1

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.
@@ -5,8 +5,8 @@
5
5
  // ids/names are validated against the kit + the roll-file regex — no path
6
6
  // from the client ever reaches the filesystem unchecked.
7
7
  import { existsSync, unlinkSync } from 'fs'
8
- import { join } from 'path'
9
- import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit } from '../kit-config.ts'
8
+ import { join, relative } from 'path'
9
+ import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
10
10
  import { randomSeed } from '../gen/comfy-gen.ts'
11
11
  import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
12
  import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
@@ -26,19 +26,26 @@ export type StudioDeps = {
26
26
 
27
27
  export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
28
28
 
29
- type ShipCtx = {
30
- assetId: string
31
- file: string
32
- asset: FoundAsset['asset']
33
- kind: FoundAsset['kind']
34
- meta: unknown
35
- rect: GroundRect | null
36
- projectRoot: string
37
- img: { toPng: typeof toPng; alphaHeight: typeof alphaHeight }
29
+ // what the studio detail view shows about an asset's plumbing (W2-2)
30
+ export type AssetInfo = {
31
+ backend: 'canvas' | 'comfy'
32
+ /** canvas rolls have no seed input — every roll is identical */
33
+ deterministic: boolean
34
+ /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
35
+ workflow: { id: string | null; path: string | null; note?: string } | null
36
+ ship: { mode: 'built-in' | 'hook' | 'record'; path?: string; note?: string }
37
+ /** live write-through target (kind.meta.file), repo-root-relative */
38
+ metaFile: string | null
39
+ kindId: string
40
+ kitSource: string
38
41
  }
39
- type ShipFn = (ctx: ShipCtx) => Promise<string | void>
40
42
 
41
- export async function serveStudio(deps: StudioDeps): Promise<Studio> {
43
+ // the studio's route table without a server: `handle` answers the studio's
44
+ // own path space ('/', '/api/…', '/img', '/panel/…') so serveStudio can own
45
+ // a port AND the shipkit hub can mount the same routes under /assets/.
46
+ export type StudioRoutes = { handle: (req: Request, pathname: string) => Promise<Response>; queue: StudioQueue }
47
+
48
+ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
42
49
  const { kit, root } = deps
43
50
  const workspace = join(root, kit.workspace)
44
51
  const dirOf = (id: string): string => assetDir(workspace, id)
@@ -54,6 +61,35 @@ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
54
61
  const customPanelsOf = (f: FoundAsset): CustomPanelSpec[] =>
55
62
  (f.kind.meta?.panels ?? []).filter((p): p is CustomPanelSpec => p.panel === 'custom')
56
63
 
64
+ // ── per-asset provenance for the studio's info surface (W2-2): where every
65
+ // roll comes from and where a pick goes. Read-only; never throws (a
66
+ // misconfigured comfy kind still shows WHY, it doesn't blank the view).
67
+ const relOf = (p: string): string => relative(root, p) || p
68
+ const infoOf = (f: FoundAsset): AssetInfo => {
69
+ const gen = f.kind.generate
70
+ const backend = gen.backend
71
+ let workflow: AssetInfo['workflow'] = null
72
+ if (gen.backend === 'comfy') {
73
+ let id = gen.workflow ?? null
74
+ let note: string | undefined
75
+ if (id == null) {
76
+ if (workflows != null && workflows.size === 1) id = [...workflows.keys()][0]!
77
+ else if (workflows != null) note = `names no workflow; ${workflows.size} loaded — set workflow on the kind`
78
+ }
79
+ const wf = id != null && workflows != null ? workflows.get(id) : undefined
80
+ if (id != null && wf == null && note == null) note = `workflow '${id}' not loaded`
81
+ const path = wf != null ? relOf(wf.jsonPath) : id != null && kit.comfy != null ? join(kit.comfy.workflows, `${id}.json`) : null
82
+ workflow = { id, path, note }
83
+ }
84
+ let ship: AssetInfo['ship']
85
+ if (f.kind.ship?.fn != null) ship = { mode: 'built-in', note: 're-renders the set on pick' }
86
+ else {
87
+ const via = f.kind.ship?.via ?? '.rv-assets/ship.ts'
88
+ ship = existsSync(join(root, via)) ? { mode: 'hook', path: via } : { mode: 'record', note: 'pick.json only — no ship hook' }
89
+ }
90
+ return { backend, deterministic: backend === 'canvas', workflow, ship, metaFile: f.kind.meta?.file ?? null, kindId: f.kind.id, kitSource: '.rv-assets/kit.ts' }
91
+ }
92
+
57
93
  // effective prompt: workspace overlay wins over the kit's builder output
58
94
  const promptOf = async (f: FoundAsset): Promise<string> =>
59
95
  (await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
@@ -104,10 +140,13 @@ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
104
140
  return errs.length > 0 ? { errors: errs } : { rect }
105
141
  }
106
142
 
107
- // ── ship hooks (probed once PER PATH — kinds may point at different
108
- // hooks via ship.via; fixing a hook needs a studio restart) ────────────
143
+ // ── ship hooks: a kind's BUILT-IN ship.fn (e.g. identityKinds re-rendering
144
+ // the set from the pick) wins; otherwise the repo module is probed once
145
+ // PER PATH (ship.via, default .rv-assets/ship.ts; fixing a hook needs a
146
+ // studio restart) ───────────────────────────────────────────────────────
109
147
  const shipFns = new Map<string, ShipFn | null>()
110
- const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
148
+ const resolveShipFn = async (f: FoundAsset): Promise<ShipFn | null> => {
149
+ if (f.kind.ship?.fn != null) return f.kind.ship.fn
111
150
  const shipPath = join(root, f.kind.ship?.via ?? '.rv-assets/ship.ts')
112
151
  let fn = shipFns.get(shipPath)
113
152
  if (fn === undefined) {
@@ -118,6 +157,10 @@ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
118
157
  } else fn = null
119
158
  shipFns.set(shipPath, fn)
120
159
  }
160
+ return fn ?? null
161
+ }
162
+ const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
163
+ const fn = await resolveShipFn(f)
121
164
  if (fn == null) return { shipped: null } // no hook: pick.json is the record
122
165
  const dir = dirOf(f.asset.id)
123
166
  const g = geometryOf(f)
@@ -139,222 +182,231 @@ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
139
182
  const bad = (msg: string): Response => json({ error: msg }, 400)
140
183
  const safeRoll = (name: unknown): string | null => (typeof name === 'string' && isRollFile(name) ? name : null)
141
184
 
142
- const server = Bun.serve({
143
- hostname: '127.0.0.1',
144
- port: kit.port,
145
- async fetch(req) {
146
- const url = new URL(req.url)
147
- const q = (k: string): string => url.searchParams.get(k) ?? ''
148
- const body = async (): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
149
-
150
- if (url.pathname === '/') return new Response(deps.page(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
185
+ const handle = async (req: Request, pathname: string): Promise<Response> => {
186
+ const url = new URL(req.url)
187
+ const q = (k: string): string => url.searchParams.get(k) ?? ''
188
+ const body = async (): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
151
189
 
152
- if (url.pathname === '/api/state') {
153
- const assets = await Promise.all(allAssets(kit).map(async (f) => {
154
- const dir = dirOf(f.asset.id)
155
- const pick = await readPick(dir)
156
- return {
157
- id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
158
- prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
159
- icon: f.kind.mode === 'icon',
160
- }
161
- }))
162
- return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
163
- }
190
+ if (pathname === '/') return new Response(deps.page(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
164
191
 
165
- if (url.pathname === '/api/asset') {
166
- const f = findAsset(kit, q('id'))
167
- if (f == null) return bad(`unknown asset '${q('id')}'`)
192
+ if (pathname === '/api/state') {
193
+ const assets = await Promise.all(allAssets(kit).map(async (f) => {
168
194
  const dir = dirOf(f.asset.id)
169
195
  const pick = await readPick(dir)
170
- const rolls = listRolls(dir).map((name) => ({ name, picked: name === pick.pick, alt: pick.alts.includes(name), hasMeta: existsSync(metaPath(dir, name)) }))
171
- const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `/panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
172
- return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom }, rolls, pick })
173
- }
196
+ return {
197
+ id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
198
+ prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
199
+ icon: f.kind.mode === 'icon',
200
+ }
201
+ }))
202
+ return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
203
+ }
174
204
 
175
- if (url.pathname.startsWith('/panel/')) {
176
- // repo-defined editor module; ids resolved against the kit — the
177
- // URL never reaches the filesystem directly
178
- const m = url.pathname.match(/^\/panel\/([^/]+)\/([^/]+)\.js$/)
179
- const kind = m == null ? null : (kit.kinds.find((k) => k.id === decodeURIComponent(m[1]!)) ?? null)
180
- const spec = kind?.meta?.panels?.find((p): p is CustomPanelSpec => p.panel === 'custom' && p.id === decodeURIComponent(m![2]!))
181
- if (spec == null) return new Response('', { status: 404 })
182
- const file = Bun.file(join(root, spec.script))
183
- if (!(await file.exists())) return new Response(`// ${spec.script} vanished after studio boot — restore it`, { status: 404 })
184
- return new Response(file, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } })
185
- }
205
+ if (pathname === '/api/asset') {
206
+ const f = findAsset(kit, q('id'))
207
+ if (f == null) return bad(`unknown asset '${q('id')}'`)
208
+ const dir = dirOf(f.asset.id)
209
+ const pick = await readPick(dir)
210
+ const rolls = listRolls(dir).map((name) => ({ name, picked: name === pick.pick, alt: pick.alts.includes(name), hasMeta: existsSync(metaPath(dir, name)) }))
211
+ const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `./panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
212
+ return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom }, info: infoOf(f), rolls, pick })
213
+ }
186
214
 
187
- if (url.pathname === '/img') {
188
- const name = safeRoll(q('name'))
189
- const f = findAsset(kit, q('id'))
190
- if (f == null || name == null) return new Response('', { status: 404 })
191
- const file = Bun.file(join(dirOf(f.asset.id), name))
192
- return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
193
- }
215
+ if (pathname.startsWith('/panel/')) {
216
+ // repo-defined editor module; ids resolved against the kit — the
217
+ // URL never reaches the filesystem directly
218
+ const m = pathname.match(/^\/panel\/([^/]+)\/([^/]+)\.js$/)
219
+ const kind = m == null ? null : (kit.kinds.find((k) => k.id === decodeURIComponent(m[1]!)) ?? null)
220
+ const spec = kind?.meta?.panels?.find((p): p is CustomPanelSpec => p.panel === 'custom' && p.id === decodeURIComponent(m![2]!))
221
+ if (spec == null) return new Response('', { status: 404 })
222
+ const file = Bun.file(join(root, spec.script))
223
+ if (!(await file.exists())) return new Response(`// ${spec.script} vanished after studio boot — restore it`, { status: 404 })
224
+ return new Response(file, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } })
225
+ }
194
226
 
195
- if (url.pathname === '/api/thumb') {
196
- const f = findAsset(kit, q('id'))
197
- if (f == null) return new Response('', { status: 404 })
198
- const dir = dirOf(f.asset.id)
199
- const pick = await readPick(dir)
200
- const name = pick.pick ?? listRolls(dir)[0]
201
- if (name == null) return new Response('', { status: 404 })
202
- const file = Bun.file(join(dir, name))
203
- return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
204
- }
227
+ if (pathname === '/img') {
228
+ const name = safeRoll(q('name'))
229
+ const f = findAsset(kit, q('id'))
230
+ if (f == null || name == null) return new Response('', { status: 404 })
231
+ const file = Bun.file(join(dirOf(f.asset.id), name))
232
+ return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
233
+ }
205
234
 
206
- if (url.pathname === '/api/rect' && req.method === 'GET') {
207
- const f = findAsset(kit, q('id'))
208
- const name = safeRoll(q('name'))
209
- if (f == null || name == null) return bad('bad id/name')
210
- if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
211
- return json({ rect: await sessionRect(f, name) })
212
- }
235
+ if (pathname === '/api/thumb') {
236
+ const f = findAsset(kit, q('id'))
237
+ if (f == null) return new Response('', { status: 404 })
238
+ const dir = dirOf(f.asset.id)
239
+ const pick = await readPick(dir)
240
+ const name = pick.pick ?? listRolls(dir)[0]
241
+ if (name == null) return new Response('', { status: 404 })
242
+ const file = Bun.file(join(dir, name))
243
+ return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
244
+ }
213
245
 
214
- if (url.pathname === '/api/catrects') {
215
- const rows = []
216
- for (const f of allAssets(kit).filter((x) => x.kind.category === q('cat'))) {
217
- const dir = dirOf(f.asset.id)
218
- const rolls = listRolls(dir)
219
- if (rolls.length === 0) continue
220
- const pick = await readPick(dir)
221
- const name = pick.pick ?? rolls[0]!
222
- const icon = f.kind.mode === 'icon'
223
- let rect: GroundRect | null = null
224
- if (!icon && geometryOf(f) != null) {
225
- rect = await sessionRect(f, name)
226
- if (f.kind.meta?.artNormalized === true && rect.art == null) {
227
- const img = join(dir, name)
228
- if (existsSync(img)) rect.art = await alphaHeight(img)
229
- }
246
+ if (pathname === '/api/rect' && req.method === 'GET') {
247
+ const f = findAsset(kit, q('id'))
248
+ const name = safeRoll(q('name'))
249
+ if (f == null || name == null) return bad('bad id/name')
250
+ if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
251
+ return json({ rect: await sessionRect(f, name) })
252
+ }
253
+
254
+ if (pathname === '/api/catrects') {
255
+ const rows = []
256
+ for (const f of allAssets(kit).filter((x) => x.kind.category === q('cat'))) {
257
+ const dir = dirOf(f.asset.id)
258
+ const rolls = listRolls(dir)
259
+ if (rolls.length === 0) continue
260
+ const pick = await readPick(dir)
261
+ const name = pick.pick ?? rolls[0]!
262
+ const icon = f.kind.mode === 'icon'
263
+ let rect: GroundRect | null = null
264
+ if (!icon && geometryOf(f) != null) {
265
+ rect = await sessionRect(f, name)
266
+ if (f.kind.meta?.artNormalized === true && rect.art == null) {
267
+ const img = join(dir, name)
268
+ if (existsSync(img)) rect.art = await alphaHeight(img)
230
269
  }
231
- rows.push({ id: f.asset.id, label: f.asset.label ?? f.asset.id, name, rect, prop: f.kind.meta?.artNormalized === true, icon })
232
270
  }
233
- return json({ rows })
271
+ rows.push({ id: f.asset.id, label: f.asset.label ?? f.asset.id, name, rect, prop: f.kind.meta?.artNormalized === true, icon })
234
272
  }
273
+ return json({ rows })
274
+ }
235
275
 
236
- if (req.method !== 'POST') return new Response('', { status: 404 })
237
- const b = await body()
276
+ if (req.method !== 'POST') return new Response('', { status: 404 })
277
+ const b = await body()
238
278
 
239
- if (url.pathname === '/api/generate') {
240
- const f = findAsset(kit, String(b.id))
241
- if (f == null) return bad(`unknown asset '${String(b.id)}'`)
242
- const count = Math.max(1, Math.min(24, Number(b.count) || 1))
243
- const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
244
- queue.enqueue([{ assetId: f.asset.id, prompt, count }])
245
- return json({ ok: true })
246
- }
247
- if (url.pathname === '/api/generate-cat') {
248
- const hit = allAssets(kit).filter((f) => f.kind.category === b.cat)
249
- if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
250
- const count = Math.max(1, Math.min(6, Number(b.count) || 1))
251
- queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
252
- return json({ ok: true })
253
- }
254
- if (url.pathname === '/api/queue/clear') {
255
- queue.clear()
256
- return json({ ok: true })
257
- }
258
- if (url.pathname === '/api/pick') {
259
- const name = safeRoll(b.name)
260
- const f = findAsset(kit, String(b.id))
261
- if (name == null || f == null) return bad('bad id/name')
262
- const dir = dirOf(f.asset.id)
263
- try {
264
- const res = await ship(f, name)
265
- await updatePick(dir, (s) => {
266
- s.pick = name
267
- s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
268
- })
269
- return json(res)
270
- } catch (e) {
271
- queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
272
- return json({ error: (e as Error).message }, 500)
273
- }
274
- }
275
- if (url.pathname === '/api/alt') {
276
- const name = safeRoll(b.name)
277
- const f = findAsset(kit, String(b.id))
278
- if (name == null || f == null) return bad('bad id/name')
279
- await updatePick(dirOf(f.asset.id), (s) => {
280
- s.alts = s.alts.includes(name) ? s.alts.filter((a) => a !== name) : [...s.alts, name]
281
- })
282
- return json({ ok: true })
283
- }
284
- if (url.pathname === '/api/discard') {
285
- const name = safeRoll(b.name)
286
- const f = findAsset(kit, String(b.id))
287
- if (name == null || f == null) return bad('bad id/name')
288
- const dir = dirOf(f.asset.id)
289
- for (const path of [join(dir, name), metaPath(dir, name), rectPath(f.asset.id, name)]) if (existsSync(path)) unlinkSync(path)
279
+ if (pathname === '/api/generate') {
280
+ const f = findAsset(kit, String(b.id))
281
+ if (f == null) return bad(`unknown asset '${String(b.id)}'`)
282
+ const count = Math.max(1, Math.min(24, Number(b.count) || 1))
283
+ const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
284
+ queue.enqueue([{ assetId: f.asset.id, prompt, count }])
285
+ return json({ ok: true })
286
+ }
287
+ if (pathname === '/api/generate-cat') {
288
+ const hit = allAssets(kit).filter((f) => f.kind.category === b.cat)
289
+ if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
290
+ const count = Math.max(1, Math.min(6, Number(b.count) || 1))
291
+ queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
292
+ return json({ ok: true })
293
+ }
294
+ if (pathname === '/api/queue/clear') {
295
+ queue.clear()
296
+ return json({ ok: true })
297
+ }
298
+ if (pathname === '/api/pick') {
299
+ const name = safeRoll(b.name)
300
+ const f = findAsset(kit, String(b.id))
301
+ if (name == null || f == null) return bad('bad id/name')
302
+ const dir = dirOf(f.asset.id)
303
+ try {
304
+ const res = await ship(f, name)
290
305
  await updatePick(dir, (s) => {
291
- if (s.pick === name) s.pick = null
292
- s.alts = s.alts.filter((a) => a !== name)
306
+ s.pick = name
307
+ s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
293
308
  })
294
- return json({ ok: true })
295
- }
296
- if (url.pathname === '/api/prompt') {
297
- if (typeof b.prompt !== 'string') return bad('prompt must be a string')
298
- const f = findAsset(kit, String(b.id))
299
- if (f == null) return bad(`unknown asset '${String(b.id)}'`)
300
- // structure is git-reviewed TS; the studio edits the OVERLAY
301
- await updatePromptOverlay(dirOf(f.asset.id), (s) => { s.prompt = (b.prompt as string) === f.asset.prompt ? null : (b.prompt as string) })
302
- return json({ ok: true })
303
- }
304
- if (url.pathname === '/api/rect') {
305
- const name = safeRoll(b.name)
306
- const f = findAsset(kit, String(b.id))
307
- if (name == null || f == null) return bad('bad id/name')
308
- if (typeof b.rect !== 'object' || b.rect == null) return bad('rect must be an object')
309
- const res = await saveRect(f, name, b.rect as Record<string, unknown>)
310
- return 'errors' in res ? bad(res.errors.join('; ')) : json({ ok: true, rect: res.rect })
309
+ return json(res)
310
+ } catch (e) {
311
+ queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
312
+ return json({ error: (e as Error).message }, 500)
311
313
  }
312
- if (url.pathname === '/api/legs-default') {
313
- // legs derived server-side so the SPA never duplicates the constants
314
- const name = safeRoll(b.name)
315
- const f = findAsset(kit, String(b.id))
316
- if (name == null || f == null) return bad('bad id/name')
317
- if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
318
- const current = await sessionRect(f, name)
319
- if (current.legs == null) {
320
- current.legs = legDefaults(current)
321
- current.legColor ??= '#c8a070'
322
- const res = await saveRect(f, name, current as unknown as Record<string, unknown>)
323
- if ('errors' in res) return bad(res.errors.join('; '))
324
- return json({ ok: true, rect: res.rect })
325
- }
326
- return json({ ok: true, rect: current }) // non-destructive: existing legs never replaced
327
- }
328
- if (url.pathname === '/api/flip') {
329
- const name = safeRoll(b.name)
330
- const f = findAsset(kit, String(b.id))
331
- if (name == null || f == null) return bad('bad id/name')
332
- const current = await sessionRect(f, name)
333
- const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
334
- const res = await saveRect(f, name, flipped)
314
+ }
315
+ if (pathname === '/api/alt') {
316
+ const name = safeRoll(b.name)
317
+ const f = findAsset(kit, String(b.id))
318
+ if (name == null || f == null) return bad('bad id/name')
319
+ await updatePick(dirOf(f.asset.id), (s) => {
320
+ s.alts = s.alts.includes(name) ? s.alts.filter((a) => a !== name) : [...s.alts, name]
321
+ })
322
+ return json({ ok: true })
323
+ }
324
+ if (pathname === '/api/discard') {
325
+ const name = safeRoll(b.name)
326
+ const f = findAsset(kit, String(b.id))
327
+ if (name == null || f == null) return bad('bad id/name')
328
+ const dir = dirOf(f.asset.id)
329
+ for (const path of [join(dir, name), metaPath(dir, name), rectPath(f.asset.id, name)]) if (existsSync(path)) unlinkSync(path)
330
+ await updatePick(dir, (s) => {
331
+ if (s.pick === name) s.pick = null
332
+ s.alts = s.alts.filter((a) => a !== name)
333
+ })
334
+ return json({ ok: true })
335
+ }
336
+ if (pathname === '/api/prompt') {
337
+ if (typeof b.prompt !== 'string') return bad('prompt must be a string')
338
+ const f = findAsset(kit, String(b.id))
339
+ if (f == null) return bad(`unknown asset '${String(b.id)}'`)
340
+ // structure is git-reviewed TS; the studio edits the OVERLAY
341
+ await updatePromptOverlay(dirOf(f.asset.id), (s) => { s.prompt = (b.prompt as string) === f.asset.prompt ? null : (b.prompt as string) })
342
+ return json({ ok: true })
343
+ }
344
+ if (pathname === '/api/rect') {
345
+ const name = safeRoll(b.name)
346
+ const f = findAsset(kit, String(b.id))
347
+ if (name == null || f == null) return bad('bad id/name')
348
+ if (typeof b.rect !== 'object' || b.rect == null) return bad('rect must be an object')
349
+ const res = await saveRect(f, name, b.rect as Record<string, unknown>)
350
+ return 'errors' in res ? bad(res.errors.join('; ')) : json({ ok: true, rect: res.rect })
351
+ }
352
+ if (pathname === '/api/legs-default') {
353
+ // legs derived server-side so the SPA never duplicates the constants
354
+ const name = safeRoll(b.name)
355
+ const f = findAsset(kit, String(b.id))
356
+ if (name == null || f == null) return bad('bad id/name')
357
+ if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
358
+ const current = await sessionRect(f, name)
359
+ if (current.legs == null) {
360
+ current.legs = legDefaults(current)
361
+ current.legColor ??= '#c8a070'
362
+ const res = await saveRect(f, name, current as unknown as Record<string, unknown>)
335
363
  if ('errors' in res) return bad(res.errors.join('; '))
336
- // flipping the CURRENT pick re-ships mirrored pixels
337
- const pick = await readPick(dirOf(f.asset.id))
338
- if (pick.pick === name) await ship(f, name).catch((e: Error) => queue.pushError(`re-ship ${f.asset.id}: ${e.message}`))
339
- return json({ ok: true, flip: res.rect.flip === true })
364
+ return json({ ok: true, rect: res.rect })
340
365
  }
341
- if (url.pathname === '/api/meta') {
342
- const name = safeRoll(b.name)
343
- const f = findAsset(kit, String(b.id))
344
- if (name == null || f == null) return bad('bad id/name')
345
- const path = metaPath(dirOf(f.asset.id), name)
346
- if (b.text === undefined) return json({ text: existsSync(path) ? await Bun.file(path).text() : '' })
347
- try {
348
- const parsed: unknown = JSON.parse(String(b.text))
349
- await Bun.write(path, JSON.stringify(parsed, null, 2) + '\n') // meta sidecars have a single writer (this route)
350
- return json({ ok: true })
351
- } catch (e) {
352
- return bad(`not valid JSON: ${(e as Error).message}`)
353
- }
366
+ return json({ ok: true, rect: current }) // non-destructive: existing legs never replaced
367
+ }
368
+ if (pathname === '/api/flip') {
369
+ const name = safeRoll(b.name)
370
+ const f = findAsset(kit, String(b.id))
371
+ if (name == null || f == null) return bad('bad id/name')
372
+ const current = await sessionRect(f, name)
373
+ const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
374
+ const res = await saveRect(f, name, flipped)
375
+ if ('errors' in res) return bad(res.errors.join('; '))
376
+ // flipping the CURRENT pick re-ships mirrored pixels
377
+ const pick = await readPick(dirOf(f.asset.id))
378
+ if (pick.pick === name) await ship(f, name).catch((e: Error) => queue.pushError(`re-ship ${f.asset.id}: ${e.message}`))
379
+ return json({ ok: true, flip: res.rect.flip === true })
380
+ }
381
+ if (pathname === '/api/meta') {
382
+ const name = safeRoll(b.name)
383
+ const f = findAsset(kit, String(b.id))
384
+ if (name == null || f == null) return bad('bad id/name')
385
+ const path = metaPath(dirOf(f.asset.id), name)
386
+ if (b.text === undefined) return json({ text: existsSync(path) ? await Bun.file(path).text() : '' })
387
+ try {
388
+ const parsed: unknown = JSON.parse(String(b.text))
389
+ await Bun.write(path, JSON.stringify(parsed, null, 2) + '\n') // meta sidecars have a single writer (this route)
390
+ return json({ ok: true })
391
+ } catch (e) {
392
+ return bad(`not valid JSON: ${(e as Error).message}`)
354
393
  }
355
- return new Response('', { status: 404 })
356
- },
357
- })
394
+ }
395
+ return new Response('', { status: 404 })
396
+ }
397
+
398
+ return { handle, queue }
399
+ }
358
400
 
359
- return { server, queue, stop: () => server.stop(true) }
401
+ // standalone studio server (`shipkit assets serve`): the same routes on
402
+ // their own port. The hub (`shipkit serve`) mounts studioRoutes under
403
+ // /assets/ instead — one route table, two front doors.
404
+ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
405
+ const routes = await studioRoutes(deps)
406
+ const server = Bun.serve({
407
+ hostname: '127.0.0.1',
408
+ port: deps.kit.port,
409
+ fetch: (req) => routes.handle(req, new URL(req.url).pathname),
410
+ })
411
+ return { server, queue: routes.queue, stop: () => server.stop(true) }
360
412
  }
@@ -0,0 +1,34 @@
1
+ // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
+ // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runServe } from '../serve/run-serve.ts'
5
+
6
+ export class ServeCommand extends Command {
7
+ static override paths = [['serve']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Serve',
11
+ description: 'ONE hub server per repo: landing page linking the wiki/agent graphs, the assets studio, and live status',
12
+ examples: [
13
+ ['serve the repo toolbox', 'shipkit serve'],
14
+ ['pin the port for this launch', 'shipkit serve --port 4804'],
15
+ ],
16
+ })
17
+
18
+ port = Option.String('--port', { required: false, description: 'hub port (default: repo.config serve.port, else hashed from the repo name into 4800-4979)' })
19
+
20
+ async execute(): Promise<number> {
21
+ let port: number | undefined
22
+ if (this.port != null) {
23
+ port = Number(this.port)
24
+ if (!Number.isInteger(port) || port <= 0) {
25
+ this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
26
+ return 1
27
+ }
28
+ }
29
+ return runServe(process.cwd(), {
30
+ print: (l) => this.context.stdout.write(`${l}\n`),
31
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
32
+ }, { port })
33
+ }
34
+ }
@@ -1,15 +1,10 @@
1
1
  // shipkit tui — the live dashboard: Tasks (the ci gate, streamed through
2
2
  // the DAG runner) + Checks (contracts + discovered *.check.ts) built in,
3
3
  // repo extras injected via repo.config `tui: { panels: [...] }`.
4
- import React from 'react'
5
- import { render } from 'ink'
6
- import { configure } from 'mobx'
4
+ // The body lives in src/tui/run-tui.ts and is loaded ONLY when the command
5
+ // runs: the react/ink chain must never load for `shipkit ci` in a repo
6
+ // without react-dom.
7
7
  import { Command } from 'clipanion'
8
- import { loadRepoConfig } from '../repo-config.ts'
9
- import { checksPanel } from '../tui/checks-panel.ts'
10
- import { tasksPanel } from '../tui/tasks-panel.ts'
11
- import { TuiApp } from '../tui/tui-app.tsx'
12
- import { TuiStore } from '../tui/tui-store.ts'
13
8
 
14
9
  export class TuiCommand extends Command {
15
10
  static override paths = [['tui']]
@@ -21,16 +16,7 @@ export class TuiCommand extends Command {
21
16
  })
22
17
 
23
18
  async execute(): Promise<number> {
24
- const root = process.cwd()
25
- const c = await loadRepoConfig(root)
26
- // playbook (build/mobx): enforceActions warns on stray mutations — the
27
- // only signal. Read-warning flags stay off: useInput handler reads are
28
- // legitimately untracked and would false-positive.
29
- configure({ enforceActions: 'always' })
30
- const store = new TuiStore(c.name, [tasksPanel(root, c), checksPanel(root), ...(c.tui?.panels ?? [])])
31
- store.refresh() // instant static paint; results stream in
32
- const app = render(React.createElement(TuiApp, { store }))
33
- await app.waitUntilExit()
34
- return 0
19
+ const { runTui } = await import('../tui/run-tui.ts')
20
+ return runTui(process.cwd())
35
21
  }
36
22
  }