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.
- package/CHANGELOG.md +82 -0
- package/README.md +23 -8
- package/package.json +29 -13
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +161 -0
- package/src/assets/kits/identity-kit.ts +80 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +122 -0
- package/src/assets/server/studio-page.ts +587 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +360 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +42 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +68 -16
- package/src/shipkit.ts +34 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -40
- 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
|
+
}
|