rvlib-shipkit 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/README.md +23 -8
- package/package.json +15 -3
- 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 +179 -0
- package/src/assets/kits/identity-kit.ts +119 -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 +142 -0
- package/src/assets/server/studio-page.ts +620 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +412 -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_Serve.ts +34 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +44 -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 +82 -15
- 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 +39 -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 -41
- package/src/cli/shipkit-wiki.ts +0 -74
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Generation queue: single worker, IN-MEMORY (never run the studio with
|
|
2
|
+
// --watch — a restart drops every queued roll; the fill CLI is the recovery
|
|
3
|
+
// path), errors surfaced in a ring buffer, never fatal.
|
|
4
|
+
export type Job = { assetId: string; prompt: string; count: number }
|
|
5
|
+
export type QueueState = { generating: boolean; current: string | null; done: number; total: number }
|
|
6
|
+
|
|
7
|
+
export class StudioQueue {
|
|
8
|
+
private jobs: Job[] = []
|
|
9
|
+
readonly state: QueueState = { generating: false, current: null, done: 0, total: 0 }
|
|
10
|
+
readonly errors: string[] = []
|
|
11
|
+
|
|
12
|
+
/** generateOne throws on failure — the queue logs and moves on */
|
|
13
|
+
constructor(private generateOne: (job: Job, seed: number) => Promise<void>, private newSeed: () => number) {}
|
|
14
|
+
|
|
15
|
+
pushError(msg: string): void {
|
|
16
|
+
this.errors.push(msg)
|
|
17
|
+
if (this.errors.length > 20) this.errors.shift()
|
|
18
|
+
console.log(`FAIL: ${msg}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get queuedCount(): number { return this.jobs.length }
|
|
22
|
+
|
|
23
|
+
clear(): void { this.jobs.length = 0 }
|
|
24
|
+
|
|
25
|
+
enqueue(newJobs: Job[]): void {
|
|
26
|
+
if (!this.state.generating) {
|
|
27
|
+
this.state.done = 0
|
|
28
|
+
this.state.total = 0
|
|
29
|
+
}
|
|
30
|
+
this.state.total += newJobs.reduce((s, j) => s + j.count, 0)
|
|
31
|
+
this.jobs.push(...newJobs)
|
|
32
|
+
if (!this.state.generating) {
|
|
33
|
+
this.state.generating = true
|
|
34
|
+
void this.workerLoop()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async workerLoop(): Promise<void> {
|
|
39
|
+
while (true) {
|
|
40
|
+
const job = this.jobs.shift()
|
|
41
|
+
if (job == null) {
|
|
42
|
+
this.state.generating = false
|
|
43
|
+
this.state.current = null
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
this.state.current = job.assetId
|
|
47
|
+
for (let i = 0; i < job.count; i++) {
|
|
48
|
+
const seed = this.newSeed()
|
|
49
|
+
try {
|
|
50
|
+
await this.generateOne(job, seed)
|
|
51
|
+
} catch (e) {
|
|
52
|
+
this.pushError(`${job.assetId} seed ${seed}: ${(e as Error).message}`)
|
|
53
|
+
}
|
|
54
|
+
this.state.done++
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// The studio HTTP server — roll variants, compare, pick, geometry metadata,
|
|
2
|
+
// category grid data. 127.0.0.1 ONLY, forever. Never run with --watch: the
|
|
3
|
+
// queue is in-memory (fill is the recovery path).
|
|
4
|
+
//
|
|
5
|
+
// ids/names are validated against the kit + the roll-file regex — no path
|
|
6
|
+
// from the client ever reaches the filesystem unchecked.
|
|
7
|
+
import { existsSync, unlinkSync } from 'fs'
|
|
8
|
+
import { join, relative } from 'path'
|
|
9
|
+
import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
|
+
import { randomSeed } from '../gen/comfy-gen.ts'
|
|
11
|
+
import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
|
|
12
|
+
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
|
+
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
14
|
+
import { readJson, updateJson } from '../json-store.ts'
|
|
15
|
+
import { assetDir, isRollFile, listRolls, metaPath, readPick, readPromptOverlay, updatePick, updatePromptOverlay } from '../roll-workspace.ts'
|
|
16
|
+
import { cleanRect, defaultLegs as legDefaults, liveRect, type GroundRect } from '../panels/geometry-rects.ts'
|
|
17
|
+
import { StudioQueue } from './studio-queue.ts'
|
|
18
|
+
|
|
19
|
+
export type StudioDeps = {
|
|
20
|
+
kit: ResolvedKit
|
|
21
|
+
root: string
|
|
22
|
+
page: () => string
|
|
23
|
+
/** test seam — defaults to the comfy engine */
|
|
24
|
+
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string) => Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
|
|
28
|
+
|
|
29
|
+
// what the studio detail view shows about an asset's plumbing (W2-2)
|
|
30
|
+
export type AssetInfo = {
|
|
31
|
+
backend: 'canvas' | 'comfy'
|
|
32
|
+
/** canvas rolls have no seed input — every roll is identical */
|
|
33
|
+
deterministic: boolean
|
|
34
|
+
/** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
|
|
35
|
+
workflow: { id: string | null; path: string | null; note?: string } | null
|
|
36
|
+
ship: { mode: 'built-in' | 'hook' | 'record'; path?: string; note?: string }
|
|
37
|
+
/** live write-through target (kind.meta.file), repo-root-relative */
|
|
38
|
+
metaFile: string | null
|
|
39
|
+
kindId: string
|
|
40
|
+
kitSource: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// the studio's route table without a server: `handle` answers the studio's
|
|
44
|
+
// own path space ('/', '/api/…', '/img', '/panel/…') so serveStudio can own
|
|
45
|
+
// a port AND the shipkit hub can mount the same routes under /assets/.
|
|
46
|
+
export type StudioRoutes = { handle: (req: Request, pathname: string) => Promise<Response>; queue: StudioQueue }
|
|
47
|
+
|
|
48
|
+
export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
49
|
+
const { kit, root } = deps
|
|
50
|
+
const workspace = join(root, kit.workspace)
|
|
51
|
+
const dirOf = (id: string): string => assetDir(workspace, id)
|
|
52
|
+
let workflows: Map<string, Workflow> | null = null
|
|
53
|
+
if (kit.comfy != null) workflows = await loadWorkflows(join(root, kit.comfy.workflows))
|
|
54
|
+
|
|
55
|
+
// custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
|
|
56
|
+
const missingPanels = missingPanelScripts(kit, root)
|
|
57
|
+
if (missingPanels.length > 0) throw new Error(`custom panel scripts missing — create them or fix the kit:\n ${missingPanels.join('\n ')}`)
|
|
58
|
+
|
|
59
|
+
const geometryOf = (f: FoundAsset): GeometryPanelSpec | null =>
|
|
60
|
+
(f.kind.meta?.panels?.find((p) => p.panel === 'geometry') as GeometryPanelSpec | undefined) ?? null
|
|
61
|
+
const customPanelsOf = (f: FoundAsset): CustomPanelSpec[] =>
|
|
62
|
+
(f.kind.meta?.panels ?? []).filter((p): p is CustomPanelSpec => p.panel === 'custom')
|
|
63
|
+
|
|
64
|
+
// ── per-asset provenance for the studio's info surface (W2-2): where every
|
|
65
|
+
// roll comes from and where a pick goes. Read-only; never throws (a
|
|
66
|
+
// misconfigured comfy kind still shows WHY, it doesn't blank the view).
|
|
67
|
+
const relOf = (p: string): string => relative(root, p) || p
|
|
68
|
+
const infoOf = (f: FoundAsset): AssetInfo => {
|
|
69
|
+
const gen = f.kind.generate
|
|
70
|
+
const backend = gen.backend
|
|
71
|
+
let workflow: AssetInfo['workflow'] = null
|
|
72
|
+
if (gen.backend === 'comfy') {
|
|
73
|
+
let id = gen.workflow ?? null
|
|
74
|
+
let note: string | undefined
|
|
75
|
+
if (id == null) {
|
|
76
|
+
if (workflows != null && workflows.size === 1) id = [...workflows.keys()][0]!
|
|
77
|
+
else if (workflows != null) note = `names no workflow; ${workflows.size} loaded — set workflow on the kind`
|
|
78
|
+
}
|
|
79
|
+
const wf = id != null && workflows != null ? workflows.get(id) : undefined
|
|
80
|
+
if (id != null && wf == null && note == null) note = `workflow '${id}' not loaded`
|
|
81
|
+
const path = wf != null ? relOf(wf.jsonPath) : id != null && kit.comfy != null ? join(kit.comfy.workflows, `${id}.json`) : null
|
|
82
|
+
workflow = { id, path, note }
|
|
83
|
+
}
|
|
84
|
+
let ship: AssetInfo['ship']
|
|
85
|
+
if (f.kind.ship?.fn != null) ship = { mode: 'built-in', note: 're-renders the set on pick' }
|
|
86
|
+
else {
|
|
87
|
+
const via = f.kind.ship?.via ?? '.rv-assets/ship.ts'
|
|
88
|
+
ship = existsSync(join(root, via)) ? { mode: 'hook', path: via } : { mode: 'record', note: 'pick.json only — no ship hook' }
|
|
89
|
+
}
|
|
90
|
+
return { backend, deterministic: backend === 'canvas', workflow, ship, metaFile: f.kind.meta?.file ?? null, kindId: f.kind.id, kitSource: '.rv-assets/kit.ts' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// effective prompt: workspace overlay wins over the kit's builder output
|
|
94
|
+
const promptOf = async (f: FoundAsset): Promise<string> =>
|
|
95
|
+
(await readPromptOverlay(dirOf(f.asset.id))).prompt ?? f.asset.prompt
|
|
96
|
+
|
|
97
|
+
// workflows are pre-loaded above (fail-fast for a comfy studio); the
|
|
98
|
+
// resolver just hands the shared roll generator the ready runtime.
|
|
99
|
+
const resolveComfy = async (): Promise<ComfyRuntime> => {
|
|
100
|
+
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
|
+
return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
|
|
102
|
+
}
|
|
103
|
+
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir) =>
|
|
104
|
+
generateRoll(found, { prompt, seed, destDir }, resolveComfy))
|
|
105
|
+
|
|
106
|
+
const queue = new StudioQueue(async (job, seed) => {
|
|
107
|
+
const found = findAsset(kit, job.assetId)
|
|
108
|
+
if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
|
|
109
|
+
await generateOne(found, job.prompt, seed, dirOf(job.assetId))
|
|
110
|
+
}, randomSeed)
|
|
111
|
+
|
|
112
|
+
// ── session/live rect plumbing ─────────────────────────────────────────
|
|
113
|
+
const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
|
|
114
|
+
const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
|
|
115
|
+
const g = geometryOf(f)!
|
|
116
|
+
const raw = await readJson<Record<string, unknown> | null>(rectPath(f.asset.id, roll), null)
|
|
117
|
+
if (raw != null) return cleanRect(raw, g.ratio)
|
|
118
|
+
const size = g.sizeByCategory?.[f.kind.category] ?? g.defaultOval.size
|
|
119
|
+
return cleanRect({ ...g.defaultOval, size }, g.ratio)
|
|
120
|
+
}
|
|
121
|
+
const writeThrough = async (f: FoundAsset, rect: GroundRect): Promise<string[]> => {
|
|
122
|
+
const errs = f.kind.meta?.validate?.(rect) ?? []
|
|
123
|
+
if (errs.length > 0) return errs
|
|
124
|
+
if (f.kind.meta?.file != null) {
|
|
125
|
+
const target = join(root, f.kind.meta.file)
|
|
126
|
+
await updateJson<{ rects: Record<string, GroundRect> }>(target, { rects: {} }, (d) => {
|
|
127
|
+
d.rects[f.asset.id] = liveRect(rect)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
const saveRect = async (f: FoundAsset, roll: string, raw: Record<string, unknown>): Promise<{ rect: GroundRect } | { errors: string[] }> => {
|
|
133
|
+
const g = geometryOf(f)
|
|
134
|
+
if (g == null) return { errors: [`kind '${f.kind.id}' has no geometry panel`] }
|
|
135
|
+
const rect = cleanRect(raw, g.ratio)
|
|
136
|
+
const img = join(dirOf(f.asset.id), roll)
|
|
137
|
+
if (existsSync(img)) rect.art = await alphaHeight(img) // measure on EVERY save
|
|
138
|
+
await Bun.write(rectPath(f.asset.id, roll), JSON.stringify(rect, null, 2) + '\n')
|
|
139
|
+
const errs = await writeThrough(f, rect)
|
|
140
|
+
return errs.length > 0 ? { errors: errs } : { rect }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── ship hooks: a kind's BUILT-IN ship.fn (e.g. identityKinds re-rendering
|
|
144
|
+
// the set from the pick) wins; otherwise the repo module is probed once
|
|
145
|
+
// PER PATH (ship.via, default .rv-assets/ship.ts; fixing a hook needs a
|
|
146
|
+
// studio restart) ───────────────────────────────────────────────────────
|
|
147
|
+
const shipFns = new Map<string, ShipFn | null>()
|
|
148
|
+
const resolveShipFn = async (f: FoundAsset): Promise<ShipFn | null> => {
|
|
149
|
+
if (f.kind.ship?.fn != null) return f.kind.ship.fn
|
|
150
|
+
const shipPath = join(root, f.kind.ship?.via ?? '.rv-assets/ship.ts')
|
|
151
|
+
let fn = shipFns.get(shipPath)
|
|
152
|
+
if (fn === undefined) {
|
|
153
|
+
if (existsSync(shipPath)) {
|
|
154
|
+
const mod = (await import(shipPath)) as { ship?: ShipFn; default?: ShipFn }
|
|
155
|
+
fn = mod.ship ?? mod.default ?? undefined // undefined on bad exports: EVERY pick keeps erroring loudly, never silently downgrades to record-only
|
|
156
|
+
if (fn == null) throw new Error(`${shipPath} exists but exports neither \`ship\` nor a default function — fix it and RESTART the studio (imports are cached)`)
|
|
157
|
+
} else fn = null
|
|
158
|
+
shipFns.set(shipPath, fn)
|
|
159
|
+
}
|
|
160
|
+
return fn ?? null
|
|
161
|
+
}
|
|
162
|
+
const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
|
|
163
|
+
const fn = await resolveShipFn(f)
|
|
164
|
+
if (fn == null) return { shipped: null } // no hook: pick.json is the record
|
|
165
|
+
const dir = dirOf(f.asset.id)
|
|
166
|
+
const g = geometryOf(f)
|
|
167
|
+
const dest = await fn({
|
|
168
|
+
assetId: f.asset.id,
|
|
169
|
+
file: join(dir, roll),
|
|
170
|
+
asset: f.asset,
|
|
171
|
+
kind: f.kind,
|
|
172
|
+
meta: await readJson(metaPath(dir, roll), null),
|
|
173
|
+
rect: g != null ? await sessionRect(f, roll) : null,
|
|
174
|
+
projectRoot: root,
|
|
175
|
+
img: { toPng, alphaHeight },
|
|
176
|
+
})
|
|
177
|
+
return { shipped: dest ?? 'shipped' }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── http ────────────────────────────────────────────────────────────────
|
|
181
|
+
const json = (data: unknown, status = 200): Response => Response.json(data, { status })
|
|
182
|
+
const bad = (msg: string): Response => json({ error: msg }, 400)
|
|
183
|
+
const safeRoll = (name: unknown): string | null => (typeof name === 'string' && isRollFile(name) ? name : null)
|
|
184
|
+
|
|
185
|
+
const handle = async (req: Request, pathname: string): Promise<Response> => {
|
|
186
|
+
const url = new URL(req.url)
|
|
187
|
+
const q = (k: string): string => url.searchParams.get(k) ?? ''
|
|
188
|
+
const body = async (): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
|
|
189
|
+
|
|
190
|
+
if (pathname === '/') return new Response(deps.page(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
|
|
191
|
+
|
|
192
|
+
if (pathname === '/api/state') {
|
|
193
|
+
const assets = await Promise.all(allAssets(kit).map(async (f) => {
|
|
194
|
+
const dir = dirOf(f.asset.id)
|
|
195
|
+
const pick = await readPick(dir)
|
|
196
|
+
return {
|
|
197
|
+
id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
|
|
198
|
+
prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
|
|
199
|
+
icon: f.kind.mode === 'icon',
|
|
200
|
+
}
|
|
201
|
+
}))
|
|
202
|
+
return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (pathname === '/api/asset') {
|
|
206
|
+
const f = findAsset(kit, q('id'))
|
|
207
|
+
if (f == null) return bad(`unknown asset '${q('id')}'`)
|
|
208
|
+
const dir = dirOf(f.asset.id)
|
|
209
|
+
const pick = await readPick(dir)
|
|
210
|
+
const rolls = listRolls(dir).map((name) => ({ name, picked: name === pick.pick, alt: pick.alts.includes(name), hasMeta: existsSync(metaPath(dir, name)) }))
|
|
211
|
+
const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `./panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
|
|
212
|
+
return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom }, info: infoOf(f), rolls, pick })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (pathname.startsWith('/panel/')) {
|
|
216
|
+
// repo-defined editor module; ids resolved against the kit — the
|
|
217
|
+
// URL never reaches the filesystem directly
|
|
218
|
+
const m = pathname.match(/^\/panel\/([^/]+)\/([^/]+)\.js$/)
|
|
219
|
+
const kind = m == null ? null : (kit.kinds.find((k) => k.id === decodeURIComponent(m[1]!)) ?? null)
|
|
220
|
+
const spec = kind?.meta?.panels?.find((p): p is CustomPanelSpec => p.panel === 'custom' && p.id === decodeURIComponent(m![2]!))
|
|
221
|
+
if (spec == null) return new Response('', { status: 404 })
|
|
222
|
+
const file = Bun.file(join(root, spec.script))
|
|
223
|
+
if (!(await file.exists())) return new Response(`// ${spec.script} vanished after studio boot — restore it`, { status: 404 })
|
|
224
|
+
return new Response(file, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (pathname === '/img') {
|
|
228
|
+
const name = safeRoll(q('name'))
|
|
229
|
+
const f = findAsset(kit, q('id'))
|
|
230
|
+
if (f == null || name == null) return new Response('', { status: 404 })
|
|
231
|
+
const file = Bun.file(join(dirOf(f.asset.id), name))
|
|
232
|
+
return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (pathname === '/api/thumb') {
|
|
236
|
+
const f = findAsset(kit, q('id'))
|
|
237
|
+
if (f == null) return new Response('', { status: 404 })
|
|
238
|
+
const dir = dirOf(f.asset.id)
|
|
239
|
+
const pick = await readPick(dir)
|
|
240
|
+
const name = pick.pick ?? listRolls(dir)[0]
|
|
241
|
+
if (name == null) return new Response('', { status: 404 })
|
|
242
|
+
const file = Bun.file(join(dir, name))
|
|
243
|
+
return (await file.exists()) ? new Response(file) : new Response('', { status: 404 })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (pathname === '/api/rect' && req.method === 'GET') {
|
|
247
|
+
const f = findAsset(kit, q('id'))
|
|
248
|
+
const name = safeRoll(q('name'))
|
|
249
|
+
if (f == null || name == null) return bad('bad id/name')
|
|
250
|
+
if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
|
|
251
|
+
return json({ rect: await sessionRect(f, name) })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (pathname === '/api/catrects') {
|
|
255
|
+
const rows = []
|
|
256
|
+
for (const f of allAssets(kit).filter((x) => x.kind.category === q('cat'))) {
|
|
257
|
+
const dir = dirOf(f.asset.id)
|
|
258
|
+
const rolls = listRolls(dir)
|
|
259
|
+
if (rolls.length === 0) continue
|
|
260
|
+
const pick = await readPick(dir)
|
|
261
|
+
const name = pick.pick ?? rolls[0]!
|
|
262
|
+
const icon = f.kind.mode === 'icon'
|
|
263
|
+
let rect: GroundRect | null = null
|
|
264
|
+
if (!icon && geometryOf(f) != null) {
|
|
265
|
+
rect = await sessionRect(f, name)
|
|
266
|
+
if (f.kind.meta?.artNormalized === true && rect.art == null) {
|
|
267
|
+
const img = join(dir, name)
|
|
268
|
+
if (existsSync(img)) rect.art = await alphaHeight(img)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
rows.push({ id: f.asset.id, label: f.asset.label ?? f.asset.id, name, rect, prop: f.kind.meta?.artNormalized === true, icon })
|
|
272
|
+
}
|
|
273
|
+
return json({ rows })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (req.method !== 'POST') return new Response('', { status: 404 })
|
|
277
|
+
const b = await body()
|
|
278
|
+
|
|
279
|
+
if (pathname === '/api/generate') {
|
|
280
|
+
const f = findAsset(kit, String(b.id))
|
|
281
|
+
if (f == null) return bad(`unknown asset '${String(b.id)}'`)
|
|
282
|
+
const count = Math.max(1, Math.min(24, Number(b.count) || 1))
|
|
283
|
+
const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
|
|
284
|
+
queue.enqueue([{ assetId: f.asset.id, prompt, count }])
|
|
285
|
+
return json({ ok: true })
|
|
286
|
+
}
|
|
287
|
+
if (pathname === '/api/generate-cat') {
|
|
288
|
+
const hit = allAssets(kit).filter((f) => f.kind.category === b.cat)
|
|
289
|
+
if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
|
|
290
|
+
const count = Math.max(1, Math.min(6, Number(b.count) || 1))
|
|
291
|
+
queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
|
|
292
|
+
return json({ ok: true })
|
|
293
|
+
}
|
|
294
|
+
if (pathname === '/api/queue/clear') {
|
|
295
|
+
queue.clear()
|
|
296
|
+
return json({ ok: true })
|
|
297
|
+
}
|
|
298
|
+
if (pathname === '/api/pick') {
|
|
299
|
+
const name = safeRoll(b.name)
|
|
300
|
+
const f = findAsset(kit, String(b.id))
|
|
301
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
302
|
+
const dir = dirOf(f.asset.id)
|
|
303
|
+
try {
|
|
304
|
+
const res = await ship(f, name)
|
|
305
|
+
await updatePick(dir, (s) => {
|
|
306
|
+
s.pick = name
|
|
307
|
+
s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
|
|
308
|
+
})
|
|
309
|
+
return json(res)
|
|
310
|
+
} catch (e) {
|
|
311
|
+
queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
|
|
312
|
+
return json({ error: (e as Error).message }, 500)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (pathname === '/api/alt') {
|
|
316
|
+
const name = safeRoll(b.name)
|
|
317
|
+
const f = findAsset(kit, String(b.id))
|
|
318
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
319
|
+
await updatePick(dirOf(f.asset.id), (s) => {
|
|
320
|
+
s.alts = s.alts.includes(name) ? s.alts.filter((a) => a !== name) : [...s.alts, name]
|
|
321
|
+
})
|
|
322
|
+
return json({ ok: true })
|
|
323
|
+
}
|
|
324
|
+
if (pathname === '/api/discard') {
|
|
325
|
+
const name = safeRoll(b.name)
|
|
326
|
+
const f = findAsset(kit, String(b.id))
|
|
327
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
328
|
+
const dir = dirOf(f.asset.id)
|
|
329
|
+
for (const path of [join(dir, name), metaPath(dir, name), rectPath(f.asset.id, name)]) if (existsSync(path)) unlinkSync(path)
|
|
330
|
+
await updatePick(dir, (s) => {
|
|
331
|
+
if (s.pick === name) s.pick = null
|
|
332
|
+
s.alts = s.alts.filter((a) => a !== name)
|
|
333
|
+
})
|
|
334
|
+
return json({ ok: true })
|
|
335
|
+
}
|
|
336
|
+
if (pathname === '/api/prompt') {
|
|
337
|
+
if (typeof b.prompt !== 'string') return bad('prompt must be a string')
|
|
338
|
+
const f = findAsset(kit, String(b.id))
|
|
339
|
+
if (f == null) return bad(`unknown asset '${String(b.id)}'`)
|
|
340
|
+
// structure is git-reviewed TS; the studio edits the OVERLAY
|
|
341
|
+
await updatePromptOverlay(dirOf(f.asset.id), (s) => { s.prompt = (b.prompt as string) === f.asset.prompt ? null : (b.prompt as string) })
|
|
342
|
+
return json({ ok: true })
|
|
343
|
+
}
|
|
344
|
+
if (pathname === '/api/rect') {
|
|
345
|
+
const name = safeRoll(b.name)
|
|
346
|
+
const f = findAsset(kit, String(b.id))
|
|
347
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
348
|
+
if (typeof b.rect !== 'object' || b.rect == null) return bad('rect must be an object')
|
|
349
|
+
const res = await saveRect(f, name, b.rect as Record<string, unknown>)
|
|
350
|
+
return 'errors' in res ? bad(res.errors.join('; ')) : json({ ok: true, rect: res.rect })
|
|
351
|
+
}
|
|
352
|
+
if (pathname === '/api/legs-default') {
|
|
353
|
+
// legs derived server-side so the SPA never duplicates the constants
|
|
354
|
+
const name = safeRoll(b.name)
|
|
355
|
+
const f = findAsset(kit, String(b.id))
|
|
356
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
357
|
+
if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
|
|
358
|
+
const current = await sessionRect(f, name)
|
|
359
|
+
if (current.legs == null) {
|
|
360
|
+
current.legs = legDefaults(current)
|
|
361
|
+
current.legColor ??= '#c8a070'
|
|
362
|
+
const res = await saveRect(f, name, current as unknown as Record<string, unknown>)
|
|
363
|
+
if ('errors' in res) return bad(res.errors.join('; '))
|
|
364
|
+
return json({ ok: true, rect: res.rect })
|
|
365
|
+
}
|
|
366
|
+
return json({ ok: true, rect: current }) // non-destructive: existing legs never replaced
|
|
367
|
+
}
|
|
368
|
+
if (pathname === '/api/flip') {
|
|
369
|
+
const name = safeRoll(b.name)
|
|
370
|
+
const f = findAsset(kit, String(b.id))
|
|
371
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
372
|
+
const current = await sessionRect(f, name)
|
|
373
|
+
const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
|
|
374
|
+
const res = await saveRect(f, name, flipped)
|
|
375
|
+
if ('errors' in res) return bad(res.errors.join('; '))
|
|
376
|
+
// flipping the CURRENT pick re-ships mirrored pixels
|
|
377
|
+
const pick = await readPick(dirOf(f.asset.id))
|
|
378
|
+
if (pick.pick === name) await ship(f, name).catch((e: Error) => queue.pushError(`re-ship ${f.asset.id}: ${e.message}`))
|
|
379
|
+
return json({ ok: true, flip: res.rect.flip === true })
|
|
380
|
+
}
|
|
381
|
+
if (pathname === '/api/meta') {
|
|
382
|
+
const name = safeRoll(b.name)
|
|
383
|
+
const f = findAsset(kit, String(b.id))
|
|
384
|
+
if (name == null || f == null) return bad('bad id/name')
|
|
385
|
+
const path = metaPath(dirOf(f.asset.id), name)
|
|
386
|
+
if (b.text === undefined) return json({ text: existsSync(path) ? await Bun.file(path).text() : '' })
|
|
387
|
+
try {
|
|
388
|
+
const parsed: unknown = JSON.parse(String(b.text))
|
|
389
|
+
await Bun.write(path, JSON.stringify(parsed, null, 2) + '\n') // meta sidecars have a single writer (this route)
|
|
390
|
+
return json({ ok: true })
|
|
391
|
+
} catch (e) {
|
|
392
|
+
return bad(`not valid JSON: ${(e as Error).message}`)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return new Response('', { status: 404 })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { handle, queue }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// standalone studio server (`shipkit assets serve`): the same routes on
|
|
402
|
+
// their own port. The hub (`shipkit serve`) mounts studioRoutes under
|
|
403
|
+
// /assets/ instead — one route table, two front doors.
|
|
404
|
+
export async function serveStudio(deps: StudioDeps): Promise<Studio> {
|
|
405
|
+
const routes = await studioRoutes(deps)
|
|
406
|
+
const server = Bun.serve({
|
|
407
|
+
hostname: '127.0.0.1',
|
|
408
|
+
port: deps.kit.port,
|
|
409
|
+
fetch: (req) => routes.handle(req, new URL(req.url).pathname),
|
|
410
|
+
})
|
|
411
|
+
return { server, queue: routes.queue, stop: () => server.stop(true) }
|
|
412
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Vite save-endpoint factory — the declarative form of the hand-rolled
|
|
2
|
+
// dev-only middleware pattern (editor POSTs → validate → write into src/ →
|
|
3
|
+
// optional registry splice). Serve-only: never active in a build. No vite
|
|
4
|
+
// dependency — the plugin shape is duck-typed.
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'fs'
|
|
6
|
+
import { dirname } from 'path'
|
|
7
|
+
|
|
8
|
+
type Middleware = (req: { method?: string; url?: string; on: (ev: string, cb: (chunk?: unknown) => void) => void }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body?: string) => void }, next: () => void) => void
|
|
9
|
+
type ViteDevServer = { middlewares: { use: (fn: Middleware) => void } }
|
|
10
|
+
export type VitePluginShape = { name: string; apply: 'serve'; configureServer: (server: ViteDevServer) => void }
|
|
11
|
+
|
|
12
|
+
export type SaveEndpoint<T> = {
|
|
13
|
+
/** e.g. '/__map/save' */
|
|
14
|
+
route: string
|
|
15
|
+
/** throw or return error strings to reject */
|
|
16
|
+
validate: (data: unknown) => T | string[]
|
|
17
|
+
/** produce { path: content } writes from the validated payload */
|
|
18
|
+
write: (data: T) => Record<string, string>
|
|
19
|
+
/** optional post-write hook (registry splice, logging) */
|
|
20
|
+
after?: (data: T, written: string[]) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveEndpoint<T>(spec: SaveEndpoint<T>): VitePluginShape {
|
|
24
|
+
return {
|
|
25
|
+
name: `atelier-save:${spec.route}`,
|
|
26
|
+
apply: 'serve',
|
|
27
|
+
configureServer(server) {
|
|
28
|
+
server.middlewares.use((req, res, next) => {
|
|
29
|
+
// query strings stripped — connect req.url carries them, the route key never does
|
|
30
|
+
const path = req.url?.split('?')[0]
|
|
31
|
+
if (req.method !== 'POST' || path !== spec.route) return next()
|
|
32
|
+
const chunks: Buffer[] = []
|
|
33
|
+
req.on('data', (c) => chunks.push(c as Buffer))
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
void (async () => {
|
|
36
|
+
res.setHeader('Content-Type', 'application/json')
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
39
|
+
const validated = spec.validate(parsed)
|
|
40
|
+
if (Array.isArray(validated)) {
|
|
41
|
+
res.statusCode = 400
|
|
42
|
+
res.end(JSON.stringify({ error: validated.join('; ') }))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const files = spec.write(validated)
|
|
46
|
+
for (const [path, content] of Object.entries(files)) {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
48
|
+
writeFileSync(path, content)
|
|
49
|
+
}
|
|
50
|
+
spec.after?.(validated, Object.keys(files))
|
|
51
|
+
res.statusCode = 200
|
|
52
|
+
res.end(JSON.stringify({ ok: true, written: Object.keys(files) }))
|
|
53
|
+
} catch (e) {
|
|
54
|
+
res.statusCode = 400
|
|
55
|
+
res.end(JSON.stringify({ error: (e as Error).message }))
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// shipkit asks — the per-repo human queue: `asks` lists, `asks check`
|
|
2
|
+
// probes + archives, `ask "<title>"` creates. Three thin commands over
|
|
3
|
+
// asks/asks-queue.ts (the plain module owns all logic).
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { Command, Option } from 'clipanion'
|
|
6
|
+
import { checkAsks, createAsk, renderAsksTable } from '../asks/asks-queue.ts'
|
|
7
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
8
|
+
|
|
9
|
+
const asksDir = async (root: string): Promise<string> => join(root, (await loadRepoConfig(root)).asks?.dir ?? 'asks')
|
|
10
|
+
|
|
11
|
+
export class AsksCommand extends Command {
|
|
12
|
+
static override paths = [['asks']]
|
|
13
|
+
|
|
14
|
+
static override usage = Command.Usage({
|
|
15
|
+
category: 'Asks',
|
|
16
|
+
description: 'list open asks — the per-repo human action queue',
|
|
17
|
+
examples: [['list open asks', 'shipkit asks']],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
async execute(): Promise<number> {
|
|
21
|
+
try {
|
|
22
|
+
const root = process.cwd()
|
|
23
|
+
const c = await loadRepoConfig(root)
|
|
24
|
+
renderAsksTable(join(root, c.asks?.dir ?? 'asks'), undefined, { emptyText: c.asks?.emptyText })
|
|
25
|
+
return 0
|
|
26
|
+
} catch (e) {
|
|
27
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AsksCheckCommand extends Command {
|
|
34
|
+
static override paths = [['asks', 'check']]
|
|
35
|
+
|
|
36
|
+
static override usage = Command.Usage({
|
|
37
|
+
category: 'Asks',
|
|
38
|
+
description: 'probe every open ask and archive the ones whose check now passes',
|
|
39
|
+
examples: [['probe + auto-close', 'shipkit asks check']],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
async execute(): Promise<number> {
|
|
43
|
+
try {
|
|
44
|
+
checkAsks(await asksDir(process.cwd()))
|
|
45
|
+
return 0
|
|
46
|
+
} catch (e) {
|
|
47
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class AskNewCommand extends Command {
|
|
54
|
+
static override paths = [['ask'], ['asks', 'new']]
|
|
55
|
+
|
|
56
|
+
static override usage = Command.Usage({
|
|
57
|
+
category: 'Asks',
|
|
58
|
+
description: 'create a new ask file in the queue',
|
|
59
|
+
examples: [
|
|
60
|
+
['minimal', 'shipkit ask "rotate the API token"'],
|
|
61
|
+
['with a reason', 'shipkit ask "buy the domain" --why "landing page is blocked on it"'],
|
|
62
|
+
['with metadata', 'shipkit ask "buy the domain" --kind action --blocks deploy --check "dig +short example.com"'],
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
title = Option.String({ required: true })
|
|
67
|
+
kind = Option.String('--kind', { required: false, description: 'ask kind: action | credential | decision | purchase' })
|
|
68
|
+
blocks = Option.String('--blocks', { required: false, description: 'what this ask blocks (task id, page, plain words)' })
|
|
69
|
+
check = Option.String('--check', { required: false, description: 'shell probe that exits 0 once the ask is done (enables auto-close)' })
|
|
70
|
+
why = Option.String('--why', { required: false, description: 'one line of context — why this is worth his time' })
|
|
71
|
+
|
|
72
|
+
async execute(): Promise<number> {
|
|
73
|
+
try {
|
|
74
|
+
const dir = await asksDir(process.cwd())
|
|
75
|
+
const file = createAsk(dir, this.title, { kind: this.kind, blocks: this.blocks, check: this.check, why: this.why })
|
|
76
|
+
this.context.stdout.write(`created ${dir}/${file}\n`)
|
|
77
|
+
return 0
|
|
78
|
+
} catch (e) {
|
|
79
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|