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,360 @@
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 } from 'path'
9
+ import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit } 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
+ 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 }
38
+ }
39
+ type ShipFn = (ctx: ShipCtx) => Promise<string | void>
40
+
41
+ export async function serveStudio(deps: StudioDeps): Promise<Studio> {
42
+ const { kit, root } = deps
43
+ const workspace = join(root, kit.workspace)
44
+ const dirOf = (id: string): string => assetDir(workspace, id)
45
+ let workflows: Map<string, Workflow> | null = null
46
+ if (kit.comfy != null) workflows = await loadWorkflows(join(root, kit.comfy.workflows))
47
+
48
+ // custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
49
+ const missingPanels = missingPanelScripts(kit, root)
50
+ if (missingPanels.length > 0) throw new Error(`custom panel scripts missing — create them or fix the kit:\n ${missingPanels.join('\n ')}`)
51
+
52
+ const geometryOf = (f: FoundAsset): GeometryPanelSpec | null =>
53
+ (f.kind.meta?.panels?.find((p) => p.panel === 'geometry') as GeometryPanelSpec | undefined) ?? null
54
+ const customPanelsOf = (f: FoundAsset): CustomPanelSpec[] =>
55
+ (f.kind.meta?.panels ?? []).filter((p): p is CustomPanelSpec => p.panel === 'custom')
56
+
57
+ // effective prompt: workspace overlay wins over the kit's builder output
58
+ const promptOf = async (f: FoundAsset): Promise<string> =>
59
+ (await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
60
+
61
+ // workflows are pre-loaded above (fail-fast for a comfy studio); the
62
+ // resolver just hands the shared roll generator the ready runtime.
63
+ const resolveComfy = async (): Promise<ComfyRuntime> => {
64
+ if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
65
+ return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
66
+ }
67
+ const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir) =>
68
+ generateRoll(found, { prompt, seed, destDir }, resolveComfy))
69
+
70
+ const queue = new StudioQueue(async (job, seed) => {
71
+ const found = findAsset(kit, job.assetId)
72
+ if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
73
+ await generateOne(found, job.prompt, seed, dirOf(job.assetId))
74
+ }, randomSeed)
75
+
76
+ // ── session/live rect plumbing ─────────────────────────────────────────
77
+ const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
78
+ const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
79
+ const g = geometryOf(f)!
80
+ const raw = await readJson<Record<string, unknown> | null>(rectPath(f.asset.id, roll), null)
81
+ if (raw != null) return cleanRect(raw, g.ratio)
82
+ const size = g.sizeByCategory?.[f.kind.category] ?? g.defaultOval.size
83
+ return cleanRect({ ...g.defaultOval, size }, g.ratio)
84
+ }
85
+ const writeThrough = async (f: FoundAsset, rect: GroundRect): Promise<string[]> => {
86
+ const errs = f.kind.meta?.validate?.(rect) ?? []
87
+ if (errs.length > 0) return errs
88
+ if (f.kind.meta?.file != null) {
89
+ const target = join(root, f.kind.meta.file)
90
+ await updateJson<{ rects: Record<string, GroundRect> }>(target, { rects: {} }, (d) => {
91
+ d.rects[f.asset.id] = liveRect(rect)
92
+ })
93
+ }
94
+ return []
95
+ }
96
+ const saveRect = async (f: FoundAsset, roll: string, raw: Record<string, unknown>): Promise<{ rect: GroundRect } | { errors: string[] }> => {
97
+ const g = geometryOf(f)
98
+ if (g == null) return { errors: [`kind '${f.kind.id}' has no geometry panel`] }
99
+ const rect = cleanRect(raw, g.ratio)
100
+ const img = join(dirOf(f.asset.id), roll)
101
+ if (existsSync(img)) rect.art = await alphaHeight(img) // measure on EVERY save
102
+ await Bun.write(rectPath(f.asset.id, roll), JSON.stringify(rect, null, 2) + '\n')
103
+ const errs = await writeThrough(f, rect)
104
+ return errs.length > 0 ? { errors: errs } : { rect }
105
+ }
106
+
107
+ // ── ship hooks (probed once PER PATH — kinds may point at different
108
+ // hooks via ship.via; fixing a hook needs a studio restart) ────────────
109
+ const shipFns = new Map<string, ShipFn | null>()
110
+ const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
111
+ const shipPath = join(root, f.kind.ship?.via ?? '.rv-assets/ship.ts')
112
+ let fn = shipFns.get(shipPath)
113
+ if (fn === undefined) {
114
+ if (existsSync(shipPath)) {
115
+ const mod = (await import(shipPath)) as { ship?: ShipFn; default?: ShipFn }
116
+ fn = mod.ship ?? mod.default ?? undefined // undefined on bad exports: EVERY pick keeps erroring loudly, never silently downgrades to record-only
117
+ 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)`)
118
+ } else fn = null
119
+ shipFns.set(shipPath, fn)
120
+ }
121
+ if (fn == null) return { shipped: null } // no hook: pick.json is the record
122
+ const dir = dirOf(f.asset.id)
123
+ const g = geometryOf(f)
124
+ const dest = await fn({
125
+ assetId: f.asset.id,
126
+ file: join(dir, roll),
127
+ asset: f.asset,
128
+ kind: f.kind,
129
+ meta: await readJson(metaPath(dir, roll), null),
130
+ rect: g != null ? await sessionRect(f, roll) : null,
131
+ projectRoot: root,
132
+ img: { toPng, alphaHeight },
133
+ })
134
+ return { shipped: dest ?? 'shipped' }
135
+ }
136
+
137
+ // ── http ────────────────────────────────────────────────────────────────
138
+ const json = (data: unknown, status = 200): Response => Response.json(data, { status })
139
+ const bad = (msg: string): Response => json({ error: msg }, 400)
140
+ const safeRoll = (name: unknown): string | null => (typeof name === 'string' && isRollFile(name) ? name : null)
141
+
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' } })
151
+
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
+ }
164
+
165
+ if (url.pathname === '/api/asset') {
166
+ const f = findAsset(kit, q('id'))
167
+ if (f == null) return bad(`unknown asset '${q('id')}'`)
168
+ const dir = dirOf(f.asset.id)
169
+ 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
+ }
174
+
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
+ }
186
+
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
+ }
194
+
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
+ }
205
+
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
+ }
213
+
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
+ }
230
+ }
231
+ rows.push({ id: f.asset.id, label: f.asset.label ?? f.asset.id, name, rect, prop: f.kind.meta?.artNormalized === true, icon })
232
+ }
233
+ return json({ rows })
234
+ }
235
+
236
+ if (req.method !== 'POST') return new Response('', { status: 404 })
237
+ const b = await body()
238
+
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)
290
+ await updatePick(dir, (s) => {
291
+ if (s.pick === name) s.pick = null
292
+ s.alts = s.alts.filter((a) => a !== name)
293
+ })
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 })
311
+ }
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)
335
+ 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 })
340
+ }
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
+ }
354
+ }
355
+ return new Response('', { status: 404 })
356
+ },
357
+ })
358
+
359
+ return { server, queue, stop: () => server.stop(true) }
360
+ }
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ // shipkit assets — asset atelier: declare kinds in .rv-assets/kit.ts, roll
2
+ // (ComfyUI or procedural canvas), curate in the studio, ship with metadata
3
+ // write-through. Thin clipanion surfaces; logic in assets/run-assets.ts.
4
+ import { Command, Option } from 'clipanion'
5
+ import { runAssetsCheck, runAssetsGen, runAssetsIdentity, runAssetsServe, runAssetsTopUp, type AssetsIo } from '../assets/run-assets.ts'
6
+
7
+ const ioOf = (cmd: Command): AssetsIo => ({
8
+ print: (l) => cmd.context.stdout.write(`${l}\n`),
9
+ printErr: (l) => cmd.context.stderr.write(`${l}\n`),
10
+ })
11
+
12
+ export class AssetsServeCommand extends Command {
13
+ static override paths = [['assets', 'serve']]
14
+
15
+ static override usage = Command.Usage({
16
+ category: 'Assets',
17
+ description: 'the studio — roll, curate, pick, ship (127.0.0.1, kit port)',
18
+ examples: [['launch the studio for the cwd kit', 'shipkit assets serve']],
19
+ })
20
+
21
+ async execute(): Promise<number> {
22
+ return runAssetsServe(process.cwd(), ioOf(this))
23
+ }
24
+ }
25
+
26
+ export class AssetsFillCommand extends Command {
27
+ static override paths = [['assets', 'fill']]
28
+
29
+ static override usage = Command.Usage({
30
+ category: 'Assets',
31
+ description: 'top rolls up to N per asset (canvas or comfy), sparsest-first',
32
+ examples: [
33
+ ['every asset up to 8 rolls', 'shipkit assets fill 8'],
34
+ ['only two categories', 'shipkit assets fill 8 icons sprites'],
35
+ ],
36
+ })
37
+
38
+ n = Option.String({ required: true })
39
+ categories = Option.Rest()
40
+
41
+ async execute(): Promise<number> {
42
+ const n = Number(this.n)
43
+ if (!Number.isInteger(n) || n <= 0) {
44
+ this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets fill <N> [category…]\n`)
45
+ return 1
46
+ }
47
+ return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'fill', n, categories: this.categories })
48
+ }
49
+ }
50
+
51
+ export class AssetsAddCommand extends Command {
52
+ static override paths = [['assets', 'add']]
53
+
54
+ static override usage = Command.Usage({
55
+ category: 'Assets',
56
+ description: 'N more rolls per asset (canvas or comfy), regardless of what it has',
57
+ examples: [['3 fresh rolls everywhere', 'shipkit assets add 3']],
58
+ })
59
+
60
+ n = Option.String({ required: true })
61
+ categories = Option.Rest()
62
+
63
+ async execute(): Promise<number> {
64
+ const n = Number(this.n)
65
+ if (!Number.isInteger(n) || n <= 0) {
66
+ this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets add <N> [category…]\n`)
67
+ return 1
68
+ }
69
+ return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'add', n, categories: this.categories })
70
+ }
71
+ }
72
+
73
+ export class AssetsGenCommand extends Command {
74
+ static override paths = [['assets', 'gen']]
75
+
76
+ static override usage = Command.Usage({
77
+ category: 'Assets',
78
+ description: 'one-shot rolls for a single asset (canvas or comfy)',
79
+ examples: [
80
+ ['one roll', 'shipkit assets gen hero'],
81
+ ['five rolls', 'shipkit assets gen hero --n 5'],
82
+ ],
83
+ })
84
+
85
+ assetId = Option.String({ required: true })
86
+ n = Option.String('--n', '1', { description: 'how many rolls (default 1)' })
87
+
88
+ async execute(): Promise<number> {
89
+ const n = Math.max(1, Number(this.n) || 1)
90
+ return runAssetsGen(process.cwd(), ioOf(this), { assetId: this.assetId, n })
91
+ }
92
+ }
93
+
94
+ export class AssetsIdentityCommand extends Command {
95
+ static override paths = [['assets', 'identity']]
96
+
97
+ static override usage = Command.Usage({
98
+ category: 'Assets',
99
+ description: 'render favicon/logo/OG from .rv-identity/brand.ts into public/',
100
+ examples: [['regenerate the identity outputs', 'shipkit assets identity']],
101
+ })
102
+
103
+ async execute(): Promise<number> {
104
+ return runAssetsIdentity(process.cwd(), ioOf(this))
105
+ }
106
+ }
107
+
108
+ export class AssetsCheckCommand extends Command {
109
+ static override paths = [['assets', 'check']]
110
+
111
+ static override usage = Command.Usage({
112
+ category: 'Assets',
113
+ description: 'kit loads, kinds valid, workflows bound, backend reachable, identity outputs present',
114
+ examples: [['health-check the asset pipeline', 'shipkit assets check']],
115
+ })
116
+
117
+ async execute(): Promise<number> {
118
+ return runAssetsCheck(process.cwd(), ioOf(this))
119
+ }
120
+ }
@@ -0,0 +1,30 @@
1
+ // shipkit check — periodic health, TTL-cached. Thin clipanion surface;
2
+ // logic in core/run-check.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runCheck } from '../core/run-check.ts'
5
+
6
+ export class CheckCommand extends Command {
7
+ static override paths = [['check']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Health',
11
+ description: 'periodic health: contracts + every *.check.ts in the repo (auto-discovered, run as subprocesses), clean passes replay from a TTL cache',
12
+ examples: [
13
+ ['run (cached where valid)', 'shipkit check'],
14
+ ['bust every cache layer', 'shipkit check --fresh'],
15
+ ['machine-readable', 'shipkit check --json'],
16
+ ],
17
+ })
18
+
19
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
20
+ fresh = Option.Boolean('--fresh', false, { description: 'bust every TTL cache layer (travels to child checks)' })
21
+
22
+ async execute(): Promise<number> {
23
+ try {
24
+ return await runCheck(process.cwd(), { json: this.json, fresh: this.fresh, print: (l) => this.context.stdout.write(`${l}\n`) })
25
+ } catch (e) {
26
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
27
+ return 1
28
+ }
29
+ }
30
+ }