rvlib-shipkit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +23 -8
  3. package/package.json +15 -3
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +179 -0
  11. package/src/assets/kits/identity-kit.ts +119 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +142 -0
  16. package/src/assets/server/studio-page.ts +620 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +412 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Serve.ts +34 -0
  27. package/src/cli/cmd_Tui.ts +36 -0
  28. package/src/cli/cmd_Wiki.ts +53 -0
  29. package/src/cli.ts +44 -25
  30. package/src/contracts/run-contracts.ts +33 -0
  31. package/src/core/check-ctx.ts +3 -16
  32. package/src/core/check-suite.ts +16 -2
  33. package/src/core/discover-checks.ts +48 -0
  34. package/src/core/run-check.ts +73 -0
  35. package/src/core/run-ci.ts +54 -0
  36. package/src/core/spawn-timeout.ts +65 -0
  37. package/src/core/task-runner.ts +36 -43
  38. package/src/core/wire.ts +20 -0
  39. package/src/core/write-status.ts +70 -0
  40. package/src/init-repo.ts +60 -0
  41. package/src/repo-config.ts +82 -15
  42. package/src/serve/hub-pages.ts +84 -0
  43. package/src/serve/run-serve.ts +85 -0
  44. package/src/serve/serve-hub.ts +77 -0
  45. package/src/shipkit.ts +39 -3
  46. package/src/tui/checks-panel.ts +39 -0
  47. package/src/tui/tasks-panel.ts +54 -0
  48. package/src/tui/tui-app.tsx +91 -0
  49. package/src/tui/tui-panel.ts +57 -0
  50. package/src/tui/tui-store.ts +98 -0
  51. package/src/wiki/run-wiki.ts +50 -0
  52. package/src/cli/shipkit-asks.ts +0 -26
  53. package/src/cli/shipkit-check.ts +0 -26
  54. package/src/cli/shipkit-ci.ts +0 -30
  55. package/src/cli/shipkit-contracts.ts +0 -26
  56. package/src/cli/shipkit-init.ts +0 -41
  57. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,58 @@
1
+ // Generation queue: single worker, IN-MEMORY (never run the studio with
2
+ // --watch — a restart drops every queued roll; the fill CLI is the recovery
3
+ // path), errors surfaced in a ring buffer, never fatal.
4
+ export type Job = { assetId: string; prompt: string; count: number }
5
+ export type QueueState = { generating: boolean; current: string | null; done: number; total: number }
6
+
7
+ export class StudioQueue {
8
+ private jobs: Job[] = []
9
+ readonly state: QueueState = { generating: false, current: null, done: 0, total: 0 }
10
+ readonly errors: string[] = []
11
+
12
+ /** generateOne throws on failure — the queue logs and moves on */
13
+ constructor(private generateOne: (job: Job, seed: number) => Promise<void>, private newSeed: () => number) {}
14
+
15
+ pushError(msg: string): void {
16
+ this.errors.push(msg)
17
+ if (this.errors.length > 20) this.errors.shift()
18
+ console.log(`FAIL: ${msg}`)
19
+ }
20
+
21
+ get queuedCount(): number { return this.jobs.length }
22
+
23
+ clear(): void { this.jobs.length = 0 }
24
+
25
+ enqueue(newJobs: Job[]): void {
26
+ if (!this.state.generating) {
27
+ this.state.done = 0
28
+ this.state.total = 0
29
+ }
30
+ this.state.total += newJobs.reduce((s, j) => s + j.count, 0)
31
+ this.jobs.push(...newJobs)
32
+ if (!this.state.generating) {
33
+ this.state.generating = true
34
+ void this.workerLoop()
35
+ }
36
+ }
37
+
38
+ private async workerLoop(): Promise<void> {
39
+ while (true) {
40
+ const job = this.jobs.shift()
41
+ if (job == null) {
42
+ this.state.generating = false
43
+ this.state.current = null
44
+ return
45
+ }
46
+ this.state.current = job.assetId
47
+ for (let i = 0; i < job.count; i++) {
48
+ const seed = this.newSeed()
49
+ try {
50
+ await this.generateOne(job, seed)
51
+ } catch (e) {
52
+ this.pushError(`${job.assetId} seed ${seed}: ${(e as Error).message}`)
53
+ }
54
+ this.state.done++
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,412 @@
1
+ // The studio HTTP server — roll variants, compare, pick, geometry metadata,
2
+ // category grid data. 127.0.0.1 ONLY, forever. Never run with --watch: the
3
+ // queue is in-memory (fill is the recovery path).
4
+ //
5
+ // ids/names are validated against the kit + the roll-file regex — no path
6
+ // from the client ever reaches the filesystem unchecked.
7
+ import { existsSync, unlinkSync } from 'fs'
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
+ import { randomSeed } from '../gen/comfy-gen.ts'
11
+ import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
+ import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
13
+ import { alphaHeight, toPng } from '../image-ops.ts'
14
+ import { readJson, updateJson } from '../json-store.ts'
15
+ import { assetDir, isRollFile, listRolls, metaPath, readPick, readPromptOverlay, updatePick, updatePromptOverlay } from '../roll-workspace.ts'
16
+ import { cleanRect, defaultLegs as legDefaults, liveRect, type GroundRect } from '../panels/geometry-rects.ts'
17
+ import { StudioQueue } from './studio-queue.ts'
18
+
19
+ export type StudioDeps = {
20
+ kit: ResolvedKit
21
+ root: string
22
+ page: () => string
23
+ /** test seam — defaults to the comfy engine */
24
+ generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string) => Promise<void>
25
+ }
26
+
27
+ export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
28
+
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
41
+ }
42
+
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> {
49
+ const { kit, root } = deps
50
+ const workspace = join(root, kit.workspace)
51
+ const dirOf = (id: string): string => assetDir(workspace, id)
52
+ let workflows: Map<string, Workflow> | null = null
53
+ if (kit.comfy != null) workflows = await loadWorkflows(join(root, kit.comfy.workflows))
54
+
55
+ // custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
56
+ const missingPanels = missingPanelScripts(kit, root)
57
+ if (missingPanels.length > 0) throw new Error(`custom panel scripts missing — create them or fix the kit:\n ${missingPanels.join('\n ')}`)
58
+
59
+ const geometryOf = (f: FoundAsset): GeometryPanelSpec | null =>
60
+ (f.kind.meta?.panels?.find((p) => p.panel === 'geometry') as GeometryPanelSpec | undefined) ?? null
61
+ const customPanelsOf = (f: FoundAsset): CustomPanelSpec[] =>
62
+ (f.kind.meta?.panels ?? []).filter((p): p is CustomPanelSpec => p.panel === 'custom')
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
+
93
+ // effective prompt: workspace overlay wins over the kit's builder output
94
+ const promptOf = async (f: FoundAsset): Promise<string> =>
95
+ (await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
96
+
97
+ // workflows are pre-loaded above (fail-fast for a comfy studio); the
98
+ // resolver just hands the shared roll generator the ready runtime.
99
+ const resolveComfy = async (): Promise<ComfyRuntime> => {
100
+ if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
101
+ return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
102
+ }
103
+ const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir) =>
104
+ generateRoll(found, { prompt, seed, destDir }, resolveComfy))
105
+
106
+ const queue = new StudioQueue(async (job, seed) => {
107
+ const found = findAsset(kit, job.assetId)
108
+ if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
109
+ await generateOne(found, job.prompt, seed, dirOf(job.assetId))
110
+ }, randomSeed)
111
+
112
+ // ── session/live rect plumbing ─────────────────────────────────────────
113
+ const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
114
+ const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
115
+ const g = geometryOf(f)!
116
+ const raw = await readJson<Record<string, unknown> | null>(rectPath(f.asset.id, roll), null)
117
+ if (raw != null) return cleanRect(raw, g.ratio)
118
+ const size = g.sizeByCategory?.[f.kind.category] ?? g.defaultOval.size
119
+ return cleanRect({ ...g.defaultOval, size }, g.ratio)
120
+ }
121
+ const writeThrough = async (f: FoundAsset, rect: GroundRect): Promise<string[]> => {
122
+ const errs = f.kind.meta?.validate?.(rect) ?? []
123
+ if (errs.length > 0) return errs
124
+ if (f.kind.meta?.file != null) {
125
+ const target = join(root, f.kind.meta.file)
126
+ await updateJson<{ rects: Record<string, GroundRect> }>(target, { rects: {} }, (d) => {
127
+ d.rects[f.asset.id] = liveRect(rect)
128
+ })
129
+ }
130
+ return []
131
+ }
132
+ const saveRect = async (f: FoundAsset, roll: string, raw: Record<string, unknown>): Promise<{ rect: GroundRect } | { errors: string[] }> => {
133
+ const g = geometryOf(f)
134
+ if (g == null) return { errors: [`kind '${f.kind.id}' has no geometry panel`] }
135
+ const rect = cleanRect(raw, g.ratio)
136
+ const img = join(dirOf(f.asset.id), roll)
137
+ if (existsSync(img)) rect.art = await alphaHeight(img) // measure on EVERY save
138
+ await Bun.write(rectPath(f.asset.id, roll), JSON.stringify(rect, null, 2) + '\n')
139
+ const errs = await writeThrough(f, rect)
140
+ return errs.length > 0 ? { errors: errs } : { rect }
141
+ }
142
+
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) ───────────────────────────────────────────────────────
147
+ const shipFns = new Map<string, ShipFn | null>()
148
+ const resolveShipFn = async (f: FoundAsset): Promise<ShipFn | null> => {
149
+ if (f.kind.ship?.fn != null) return f.kind.ship.fn
150
+ const shipPath = join(root, f.kind.ship?.via ?? '.rv-assets/ship.ts')
151
+ let fn = shipFns.get(shipPath)
152
+ if (fn === undefined) {
153
+ if (existsSync(shipPath)) {
154
+ const mod = (await import(shipPath)) as { ship?: ShipFn; default?: ShipFn }
155
+ fn = mod.ship ?? mod.default ?? undefined // undefined on bad exports: EVERY pick keeps erroring loudly, never silently downgrades to record-only
156
+ if (fn == null) throw new Error(`${shipPath} exists but exports neither \`ship\` nor a default function — fix it and RESTART the studio (imports are cached)`)
157
+ } else fn = null
158
+ shipFns.set(shipPath, fn)
159
+ }
160
+ return fn ?? null
161
+ }
162
+ const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
163
+ const fn = await resolveShipFn(f)
164
+ if (fn == null) return { shipped: null } // no hook: pick.json is the record
165
+ const dir = dirOf(f.asset.id)
166
+ const g = geometryOf(f)
167
+ const dest = await fn({
168
+ assetId: f.asset.id,
169
+ file: join(dir, roll),
170
+ asset: f.asset,
171
+ kind: f.kind,
172
+ meta: await readJson(metaPath(dir, roll), null),
173
+ rect: g != null ? await sessionRect(f, roll) : null,
174
+ projectRoot: root,
175
+ img: { toPng, alphaHeight },
176
+ })
177
+ return { shipped: dest ?? 'shipped' }
178
+ }
179
+
180
+ // ── http ────────────────────────────────────────────────────────────────
181
+ const json = (data: unknown, status = 200): Response => Response.json(data, { status })
182
+ const bad = (msg: string): Response => json({ error: msg }, 400)
183
+ const safeRoll = (name: unknown): string | null => (typeof name === 'string' && isRollFile(name) ? name : null)
184
+
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>
189
+
190
+ if (pathname === '/') return new Response(deps.page(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
191
+
192
+ if (pathname === '/api/state') {
193
+ const assets = await Promise.all(allAssets(kit).map(async (f) => {
194
+ const dir = dirOf(f.asset.id)
195
+ const pick = await readPick(dir)
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
+ }
204
+
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
+ }
214
+
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
+ }
226
+
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
+ }
234
+
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
+ }
245
+
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)
269
+ }
270
+ }
271
+ rows.push({ id: f.asset.id, label: f.asset.label ?? f.asset.id, name, rect, prop: f.kind.meta?.artNormalized === true, icon })
272
+ }
273
+ return json({ rows })
274
+ }
275
+
276
+ if (req.method !== 'POST') return new Response('', { status: 404 })
277
+ const b = await body()
278
+
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)
305
+ await updatePick(dir, (s) => {
306
+ s.pick = name
307
+ s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
308
+ })
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)
313
+ }
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>)
363
+ if ('errors' in res) return bad(res.errors.join('; '))
364
+ return json({ ok: true, rect: res.rect })
365
+ }
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}`)
393
+ }
394
+ }
395
+ return new Response('', { status: 404 })
396
+ }
397
+
398
+ return { handle, queue }
399
+ }
400
+
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) }
412
+ }
@@ -0,0 +1,62 @@
1
+ // Vite save-endpoint factory — the declarative form of the hand-rolled
2
+ // dev-only middleware pattern (editor POSTs → validate → write into src/ →
3
+ // optional registry splice). Serve-only: never active in a build. No vite
4
+ // dependency — the plugin shape is duck-typed.
5
+ import { mkdirSync, writeFileSync } from 'fs'
6
+ import { dirname } from 'path'
7
+
8
+ type Middleware = (req: { method?: string; url?: string; on: (ev: string, cb: (chunk?: unknown) => void) => void }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body?: string) => void }, next: () => void) => void
9
+ type ViteDevServer = { middlewares: { use: (fn: Middleware) => void } }
10
+ export type VitePluginShape = { name: string; apply: 'serve'; configureServer: (server: ViteDevServer) => void }
11
+
12
+ export type SaveEndpoint<T> = {
13
+ /** e.g. '/__map/save' */
14
+ route: string
15
+ /** throw or return error strings to reject */
16
+ validate: (data: unknown) => T | string[]
17
+ /** produce { path: content } writes from the validated payload */
18
+ write: (data: T) => Record<string, string>
19
+ /** optional post-write hook (registry splice, logging) */
20
+ after?: (data: T, written: string[]) => void
21
+ }
22
+
23
+ export function saveEndpoint<T>(spec: SaveEndpoint<T>): VitePluginShape {
24
+ return {
25
+ name: `atelier-save:${spec.route}`,
26
+ apply: 'serve',
27
+ configureServer(server) {
28
+ server.middlewares.use((req, res, next) => {
29
+ // query strings stripped — connect req.url carries them, the route key never does
30
+ const path = req.url?.split('?')[0]
31
+ if (req.method !== 'POST' || path !== spec.route) return next()
32
+ const chunks: Buffer[] = []
33
+ req.on('data', (c) => chunks.push(c as Buffer))
34
+ req.on('end', () => {
35
+ void (async () => {
36
+ res.setHeader('Content-Type', 'application/json')
37
+ try {
38
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
39
+ const validated = spec.validate(parsed)
40
+ if (Array.isArray(validated)) {
41
+ res.statusCode = 400
42
+ res.end(JSON.stringify({ error: validated.join('; ') }))
43
+ return
44
+ }
45
+ const files = spec.write(validated)
46
+ for (const [path, content] of Object.entries(files)) {
47
+ mkdirSync(dirname(path), { recursive: true })
48
+ writeFileSync(path, content)
49
+ }
50
+ spec.after?.(validated, Object.keys(files))
51
+ res.statusCode = 200
52
+ res.end(JSON.stringify({ ok: true, written: Object.keys(files) }))
53
+ } catch (e) {
54
+ res.statusCode = 400
55
+ res.end(JSON.stringify({ error: (e as Error).message }))
56
+ }
57
+ })()
58
+ })
59
+ })
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,83 @@
1
+ // shipkit asks — the per-repo human queue: `asks` lists, `asks check`
2
+ // probes + archives, `ask "<title>"` creates. Three thin commands over
3
+ // asks/asks-queue.ts (the plain module owns all logic).
4
+ import { join } from 'path'
5
+ import { Command, Option } from 'clipanion'
6
+ import { checkAsks, createAsk, renderAsksTable } from '../asks/asks-queue.ts'
7
+ import { loadRepoConfig } from '../repo-config.ts'
8
+
9
+ const asksDir = async (root: string): Promise<string> => join(root, (await loadRepoConfig(root)).asks?.dir ?? 'asks')
10
+
11
+ export class AsksCommand extends Command {
12
+ static override paths = [['asks']]
13
+
14
+ static override usage = Command.Usage({
15
+ category: 'Asks',
16
+ description: 'list open asks — the per-repo human action queue',
17
+ examples: [['list open asks', 'shipkit asks']],
18
+ })
19
+
20
+ async execute(): Promise<number> {
21
+ try {
22
+ const root = process.cwd()
23
+ const c = await loadRepoConfig(root)
24
+ renderAsksTable(join(root, c.asks?.dir ?? 'asks'), undefined, { emptyText: c.asks?.emptyText })
25
+ return 0
26
+ } catch (e) {
27
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
28
+ return 1
29
+ }
30
+ }
31
+ }
32
+
33
+ export class AsksCheckCommand extends Command {
34
+ static override paths = [['asks', 'check']]
35
+
36
+ static override usage = Command.Usage({
37
+ category: 'Asks',
38
+ description: 'probe every open ask and archive the ones whose check now passes',
39
+ examples: [['probe + auto-close', 'shipkit asks check']],
40
+ })
41
+
42
+ async execute(): Promise<number> {
43
+ try {
44
+ checkAsks(await asksDir(process.cwd()))
45
+ return 0
46
+ } catch (e) {
47
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
48
+ return 1
49
+ }
50
+ }
51
+ }
52
+
53
+ export class AskNewCommand extends Command {
54
+ static override paths = [['ask'], ['asks', 'new']]
55
+
56
+ static override usage = Command.Usage({
57
+ category: 'Asks',
58
+ description: 'create a new ask file in the queue',
59
+ examples: [
60
+ ['minimal', 'shipkit ask "rotate the API token"'],
61
+ ['with a reason', 'shipkit ask "buy the domain" --why "landing page is blocked on it"'],
62
+ ['with metadata', 'shipkit ask "buy the domain" --kind action --blocks deploy --check "dig +short example.com"'],
63
+ ],
64
+ })
65
+
66
+ title = Option.String({ required: true })
67
+ kind = Option.String('--kind', { required: false, description: 'ask kind: action | credential | decision | purchase' })
68
+ blocks = Option.String('--blocks', { required: false, description: 'what this ask blocks (task id, page, plain words)' })
69
+ check = Option.String('--check', { required: false, description: 'shell probe that exits 0 once the ask is done (enables auto-close)' })
70
+ why = Option.String('--why', { required: false, description: 'one line of context — why this is worth his time' })
71
+
72
+ async execute(): Promise<number> {
73
+ try {
74
+ const dir = await asksDir(process.cwd())
75
+ const file = createAsk(dir, this.title, { kind: this.kind, blocks: this.blocks, check: this.check, why: this.why })
76
+ this.context.stdout.write(`created ${dir}/${file}\n`)
77
+ return 0
78
+ } catch (e) {
79
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
80
+ return 1
81
+ }
82
+ }
83
+ }