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