rvlib-shipkit 0.11.0 → 0.14.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 +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -38
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Media views — topbar, info chips, stage (image/audio + geometry), roll
|
|
2
|
+
// strip, category grid + size-true lineup, drawer (asset json · roll meta ·
|
|
3
|
+
// repo-defined custom panels). Feature parity with the deleted vanilla
|
|
4
|
+
// studio SPA; state lives in MediaSvc (components read and call).
|
|
5
|
+
import { runInAction } from 'mobx'
|
|
6
|
+
import { observer } from 'mobx-react-lite'
|
|
7
|
+
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
|
8
|
+
import type { HubSt } from './hub-st.ts'
|
|
9
|
+
import { isAud, rollUrl, thumbUrl } from './media-svc.ts'
|
|
10
|
+
import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
|
|
11
|
+
import type { CatRow } from './hub-types.ts'
|
|
12
|
+
|
|
13
|
+
const TEX = 1024
|
|
14
|
+
const MIX_UNIT = 24
|
|
15
|
+
const MIX_GROUND = 44
|
|
16
|
+
|
|
17
|
+
export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
|
|
18
|
+
const m = app.media
|
|
19
|
+
const fileRef = useRef<HTMLInputElement>(null)
|
|
20
|
+
const taRef = useRef<HTMLTextAreaElement>(null)
|
|
21
|
+
const manual = m.cur?.manual === true
|
|
22
|
+
const det = m.deterministic
|
|
23
|
+
// auto-grow on EVERY prompt change, programmatic (selection) included —
|
|
24
|
+
// the css max-height caps it, overflow scrolls only past ~14 lines
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const t = taRef.current
|
|
27
|
+
if (t == null) return
|
|
28
|
+
t.style.height = 'auto'
|
|
29
|
+
t.style.height = `${t.scrollHeight + 2}px`
|
|
30
|
+
}, [m.prompt])
|
|
31
|
+
return (
|
|
32
|
+
<div className="bar">
|
|
33
|
+
{/* the prompt AUTOSAVES (debounced) — no save button, no fear */}
|
|
34
|
+
<textarea className="prompt" ref={taRef} value={m.prompt} placeholder="select an asset ←"
|
|
35
|
+
onChange={(e) => m.setPrompt(e.target.value)} />
|
|
36
|
+
<div className="toolbar">
|
|
37
|
+
{!manual ? <button disabled={m.curId == null} onClick={() => void m.generate()}>Generate</button> : null}
|
|
38
|
+
{!manual ? (
|
|
39
|
+
<label className="xn" title="rolls per generate">×
|
|
40
|
+
<select value={m.n} disabled={det} onChange={(e) => runInAction(() => { m.n = e.target.value })}>
|
|
41
|
+
{['1', '3', '6', '9', '12'].map((v) => <option key={v}>{v}</option>)}
|
|
42
|
+
</select>
|
|
43
|
+
</label>
|
|
44
|
+
) : null}
|
|
45
|
+
<button className="ghost" title="add files as rolls (or drag-drop onto the strip)" onClick={() => fileRef.current?.click()}>upload</button>
|
|
46
|
+
<input type="file" ref={fileRef} multiple style={{ display: 'none' }}
|
|
47
|
+
accept={m.cur?.media === 'audio' ? '.mp3,.wav,.ogg,.m4a' : '.png,.webp'}
|
|
48
|
+
onChange={(e) => { if (e.target.files != null) void m.upload(e.target.files); e.target.value = '' }} />
|
|
49
|
+
<button className="ghost" title="copy the prompt" onClick={() => m.copyPrompt()}>copy</button>
|
|
50
|
+
{det || manual ? <span className="detnote">{manual ? 'manual — upload files (drag-drop works)' : 'deterministic — rolls identical'}</span> : null}
|
|
51
|
+
<span className="sp" />
|
|
52
|
+
{m.generating ? <button className="ghost" title="interrupt the current job (queued jobs stay)" onClick={() => m.stopCurrent()}>stop current</button> : null}
|
|
53
|
+
{m.queued > 0 ? <button className="ghost" title="drop queued jobs" onClick={() => m.clearQueue()}>clear queue ({m.queued})</button> : null}
|
|
54
|
+
{m.generating && m.progress?.max ? (
|
|
55
|
+
<span className="progbar"><i style={{ width: `${Math.round((100 * m.progress.value) / m.progress.max)}%` }} /></span>
|
|
56
|
+
) : null}
|
|
57
|
+
<span className="status">{m.promptMsg !== '' ? m.promptMsg : m.generating ? m.genLine : m.statusLine}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const InfoChips = observer(({ app }: { app: HubSt }) => {
|
|
64
|
+
const info = app.media.detail?.info
|
|
65
|
+
if (info == null) return null
|
|
66
|
+
const chips: { k: string; v: ReactNode; warn?: boolean }[] = [{ k: 'backend', v: info.backend }]
|
|
67
|
+
if (info.workflow != null) {
|
|
68
|
+
const w = info.workflow
|
|
69
|
+
if (w.id != null) chips.push({ k: 'workflow', v: <>{w.id}{w.path != null ? <> <code>{w.path}</code></> : null}</> })
|
|
70
|
+
else chips.push({ k: 'workflow', v: w.note ?? 'unresolved', warn: true })
|
|
71
|
+
}
|
|
72
|
+
const sh = info.ship
|
|
73
|
+
chips.push({ k: 'ship', v: sh.mode === 'hook' ? <code>{sh.path}</code> : sh.mode === 'built-in' ? `built-in hook — ${sh.note ?? ''}` : sh.note ?? 'pick.json only' })
|
|
74
|
+
if (info.metaFile != null) chips.push({ k: 'meta', v: <code>{info.metaFile}</code> })
|
|
75
|
+
chips.push({ k: 'kit', v: <><code>{info.kitSource}</code> · {info.kindId}</> })
|
|
76
|
+
return (
|
|
77
|
+
<div className="info" style={{ display: 'flex' }}>
|
|
78
|
+
{chips.map((c, i) => <span key={i} className={`chip${c.warn === true ? ' warn' : ''}`}><b>{c.k}</b> {c.v}</span>)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const AssetStage = observer(({ app }: { app: HubSt }) => {
|
|
84
|
+
const m = app.media
|
|
85
|
+
const stageRef = useRef<HTMLDivElement>(null)
|
|
86
|
+
const imgRef = useRef<HTMLImageElement>(null)
|
|
87
|
+
const [box, setBox] = useState<Box | null>(null)
|
|
88
|
+
const remeasure = (): void => setBox(measureBox(stageRef.current, imgRef.current))
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
window.addEventListener('resize', remeasure)
|
|
91
|
+
return () => window.removeEventListener('resize', remeasure)
|
|
92
|
+
}, [])
|
|
93
|
+
const rolls = m.detail?.rolls ?? []
|
|
94
|
+
const genHere = m.curId != null && m.pending.includes(m.curId)
|
|
95
|
+
const shown = m.hover ?? m.active
|
|
96
|
+
let content: ReactNode
|
|
97
|
+
if (shown == null) content = <div className="ph">{genHere ? 'generating…' : rolls.length === 0 ? 'no rolls yet' : 'Pick an asset on the left, then Generate.'}</div>
|
|
98
|
+
else if (isAud(shown)) content = (
|
|
99
|
+
// key = the roll identity: heartbeats re-render but never recreate
|
|
100
|
+
// the element, so playback survives; the src carries no cache-bust
|
|
101
|
+
<div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
|
|
102
|
+
)
|
|
103
|
+
else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
|
|
104
|
+
return (
|
|
105
|
+
<div className="stage" ref={stageRef}>
|
|
106
|
+
{content}
|
|
107
|
+
{shown != null && !isAud(shown) ? <GeometryLayer app={app} box={box} /> : null}
|
|
108
|
+
<Rectbar app={app} />
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export const RollStrip = observer(({ app }: { app: HubSt }) => {
|
|
114
|
+
const m = app.media
|
|
115
|
+
const rolls = m.detail?.rolls ?? []
|
|
116
|
+
const genHere = m.curId != null && m.pending.includes(m.curId)
|
|
117
|
+
const onDrop = (e: DragEvent<HTMLDivElement>): void => { e.preventDefault(); void m.upload(e.dataTransfer.files) }
|
|
118
|
+
if (rolls.length === 0) return (
|
|
119
|
+
<div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
|
|
120
|
+
{genHere ? <div className="vig gen"><div className="spin" />generating…</div>
|
|
121
|
+
: <div className="ph">{m.cur?.manual === true ? 'no files — upload (or drag-drop here)' : 'no rolls — hit Generate'}</div>}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
return (
|
|
125
|
+
<div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
|
|
126
|
+
{/* rolls are newest-first; generation lands where the eye is */}
|
|
127
|
+
{genHere ? <div className="vig gen"><div className="spin" />generating…</div> : null}
|
|
128
|
+
{rolls.map((x) => (
|
|
129
|
+
<div key={x.name}
|
|
130
|
+
className={`vig${isAud(x.name) ? ' aud' : ''}${x.name === m.active ? ' active' : ''}${x.picked ? ' picked' : ''}${x.alt ? ' alt' : ''}`}
|
|
131
|
+
// hover-preview stays image-only — swapping an <audio> mid-playback would cut the sound
|
|
132
|
+
onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
|
|
133
|
+
onMouseLeave={() => runInAction(() => { m.hover = null })}
|
|
134
|
+
onClick={() => m.setActive(x.name)}>
|
|
135
|
+
{isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
|
|
136
|
+
{x.picked ? <span className="st">★</span> : null}
|
|
137
|
+
<div className="btns">
|
|
138
|
+
<button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
|
|
139
|
+
<button className="b-alt" onClick={(e) => { e.stopPropagation(); void m.alt(x.name) }}>alt</button>
|
|
140
|
+
<button className="b-del" onClick={(e) => { e.stopPropagation(); void m.discard(x.name) }}>✕</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ── category grid + size-true lineup ─────────────────────────────────────
|
|
149
|
+
const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
|
|
150
|
+
const items = rows.filter((r) => !r.icon && r.rect != null).sort((a, b) => a.rect!.size - b.rect!.size || a.label.localeCompare(b.label))
|
|
151
|
+
const boxH = 420
|
|
152
|
+
const baseY = boxH - MIX_GROUND
|
|
153
|
+
let cx = 30
|
|
154
|
+
const sprites: ReactNode[] = []
|
|
155
|
+
for (const it of items) {
|
|
156
|
+
const rect = it.rect!
|
|
157
|
+
const ts = (rect.size * MIX_UNIT) / rect.w
|
|
158
|
+
const slot = Math.max(rect.size * MIX_UNIT * 1.35, TEX * ts * 0.62, 40)
|
|
159
|
+
cx += slot / 2
|
|
160
|
+
const fx = rect.flip === true ? TEX - rect.x - rect.w : rect.x
|
|
161
|
+
sprites.push(
|
|
162
|
+
<img key={it.id} className="mixsprite" src={thumbUrl(it.id)} style={{
|
|
163
|
+
width: TEX * ts, height: TEX * ts,
|
|
164
|
+
left: cx - (fx + rect.w / 2) * ts, top: baseY - (rect.y + rect.h / 2) * ts,
|
|
165
|
+
transform: rect.flip === true ? 'scaleX(-1)' : undefined, zIndex: Math.round(cx),
|
|
166
|
+
}} />,
|
|
167
|
+
<div key={`${it.id}-lb`} className="mixlabel" style={{ left: cx, bottom: 14 }}>{it.label} · {+rect.size.toFixed(2)}</div>,
|
|
168
|
+
)
|
|
169
|
+
cx += slot / 2 + 10
|
|
170
|
+
}
|
|
171
|
+
const rulers: ReactNode[] = []
|
|
172
|
+
for (let u = 5; u * MIX_UNIT < baseY; u += 5)
|
|
173
|
+
rulers.push(<div key={u} className={`rlabel${u % 10 === 0 ? ' b' : ''}`} style={{ bottom: MIX_GROUND + u * MIX_UNIT - 5 }}>{u}</div>)
|
|
174
|
+
return (
|
|
175
|
+
<div id="mixsec"><div id="mixbox"><div id="mixinner" style={{ width: cx + 30, minWidth: '100%' }}>
|
|
176
|
+
<div className="ruler" />
|
|
177
|
+
<div id="mixground" />
|
|
178
|
+
{sprites}{rulers}
|
|
179
|
+
</div></div></div>
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
184
|
+
const m = app.media
|
|
185
|
+
const cat = m.catMode
|
|
186
|
+
if (cat == null) return null
|
|
187
|
+
const pc = (v: number): string => `${v / (TEX / 100)}%`
|
|
188
|
+
return (
|
|
189
|
+
<div id="catgrid" style={{ display: 'block' }}>
|
|
190
|
+
<div id="allbar">
|
|
191
|
+
<label>size for ALL {cat} <input type="number" step={0.1} min={0.1} value={m.allSize}
|
|
192
|
+
onChange={(e) => runInAction(() => { m.allSize = e.target.value })} /></label>
|
|
193
|
+
<button className="ghost" onClick={() => void m.applyAllSize()}>apply</button>
|
|
194
|
+
<span className="jmsg">{m.allMsg}</span>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="cards">
|
|
197
|
+
{m.catRows.map((row) => {
|
|
198
|
+
const fl = row.rect?.flip === true
|
|
199
|
+
return (
|
|
200
|
+
// the WHOLE card jumps into the asset (his order
|
|
201
|
+
// 2026-07-23) — inner controls stopPropagation
|
|
202
|
+
<div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
|
|
203
|
+
{isAud(row.name)
|
|
204
|
+
? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
|
|
205
|
+
: <div className="th">
|
|
206
|
+
<img src={thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
|
|
207
|
+
{row.rect != null ? <div className="ovmini" style={{
|
|
208
|
+
left: pc(fl ? TEX - row.rect.x - row.rect.w : row.rect.x), top: pc(row.rect.y),
|
|
209
|
+
width: pc(row.rect.w), height: pc(row.rect.h),
|
|
210
|
+
}} /> : null}
|
|
211
|
+
{m.pending.includes(row.id) ? <div className="spin" /> : null}
|
|
212
|
+
</div>}
|
|
213
|
+
<div className="nm">{row.label}</div>
|
|
214
|
+
{row.icon || isAud(row.name) ? null : (
|
|
215
|
+
<div className="row" onClick={(e) => e.stopPropagation()}>
|
|
216
|
+
{/* commit-on-blur/Enter like the vanilla onchange (one POST,
|
|
217
|
+
not one per keystroke); the size in the key remounts the
|
|
218
|
+
input when apply-all changes it programmatically */}
|
|
219
|
+
<label>size <input key={`${row.id}:${row.rect?.size ?? 1}`} type="number" step={0.1} min={0.1}
|
|
220
|
+
defaultValue={row.rect?.size ?? 1}
|
|
221
|
+
onBlur={(e) => void m.setSize(row.id, row.name, +e.target.value)}
|
|
222
|
+
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} /></label>
|
|
223
|
+
<button className="ghost" onClick={() => void m.toggleFlip(row.id, row.name)}>⇆ flip</button>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
<MixLineup rows={m.catRows} />
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ── drawer: asset json (read-only) · roll meta · custom panels ───────────
|
|
236
|
+
const PanelHost = observer(({ app }: { app: HubSt }) => {
|
|
237
|
+
const m = app.media
|
|
238
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
239
|
+
const c = m.detail?.asset.custom.find((x) => `c:${x.id}` === m.tab) ?? null
|
|
240
|
+
const key = `${m.curId}|${m.active}|${c?.id ?? ''}`
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
const host = ref.current
|
|
243
|
+
if (host == null) return
|
|
244
|
+
if (c == null || m.curId == null || m.active == null) {
|
|
245
|
+
host.textContent = c == null ? 'panel not on this asset' : 'no roll selected — generate or pick one first'
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
let dead = false
|
|
249
|
+
let mod: Record<string, unknown> | null = null
|
|
250
|
+
void (async () => {
|
|
251
|
+
try {
|
|
252
|
+
// repo-defined editor module, served by the media API
|
|
253
|
+
mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
|
|
254
|
+
if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
|
|
255
|
+
if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, m.panelApi(c))
|
|
256
|
+
} catch (e) {
|
|
257
|
+
host.textContent = `panel FAILED: ${(e as Error).message}` // loud in the drawer, never blank
|
|
258
|
+
}
|
|
259
|
+
})()
|
|
260
|
+
return () => {
|
|
261
|
+
dead = true
|
|
262
|
+
if (typeof mod?.unmount === 'function') void (mod.unmount as () => unknown)()
|
|
263
|
+
host.innerHTML = ''
|
|
264
|
+
}
|
|
265
|
+
}, [key])
|
|
266
|
+
return <div ref={ref} />
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
export const Drawer = observer(({ app }: { app: HubSt }) => {
|
|
270
|
+
const m = app.media
|
|
271
|
+
const customs = m.detail?.asset.custom ?? []
|
|
272
|
+
const isCustom = m.tab.startsWith('c:')
|
|
273
|
+
return (
|
|
274
|
+
<div className="drawer">
|
|
275
|
+
<div className="tabs">
|
|
276
|
+
{[{ id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }, ...customs.map((c) => ({ id: `c:${c.id}`, label: c.id }))].map((t) => (
|
|
277
|
+
<button key={t.id} className={m.tab === t.id ? 'on' : ''} onClick={() => m.setTab(t.id)}>{t.label}</button>
|
|
278
|
+
))}
|
|
279
|
+
<span className="sp" />
|
|
280
|
+
<button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>JSON ▾</button>
|
|
281
|
+
</div>
|
|
282
|
+
{m.drawerOpen ? (
|
|
283
|
+
<div id="drawerbody" className="on">
|
|
284
|
+
{isCustom ? <PanelHost app={app} /> : (
|
|
285
|
+
<div>
|
|
286
|
+
<textarea id="jsonta" spellCheck={false} readOnly={m.tab === 'asset'} value={m.jsonText}
|
|
287
|
+
onChange={(e) => runInAction(() => { m.jsonText = e.target.value })} />
|
|
288
|
+
<div className="jrow">
|
|
289
|
+
{/* kind/asset structure is git-reviewed TS — read-only here */}
|
|
290
|
+
<button disabled={m.tab === 'asset'} onClick={() => void m.saveMeta()}>save</button>
|
|
291
|
+
<span className="jmsg">{m.jsonMsg}</span>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
})
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { existsSync, readFileSync } from 'fs'
|
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
11
|
import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
|
|
12
|
-
import { page as studioPage } from '../assets/server/studio-page.ts'
|
|
13
12
|
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
14
13
|
import { projectNeeds } from '../collections/collection-types.ts'
|
|
14
|
+
import { graphsCollection } from '../collections/graphs.ts'
|
|
15
15
|
import { pingTick } from '../collections/pings.ts'
|
|
16
16
|
import { buildCollections } from '../collections/providers.ts'
|
|
17
17
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
@@ -34,35 +34,47 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
34
34
|
const agentGraph = existsSync(agentRoot)
|
|
35
35
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
36
36
|
: null
|
|
37
|
-
|
|
37
|
+
// ONE page for every repo (his rule 2026-07-23): the studio ALWAYS
|
|
38
|
+
// mounts — kit-less repos get an empty media section, never a second
|
|
39
|
+
// page. A broken kit degrades to the same empty kit, loudly.
|
|
38
40
|
let kit: ResolvedKit | null = null
|
|
39
41
|
if (existsSync(join(root, '.rv-assets/kit.ts'))) {
|
|
40
|
-
// a broken kit must not take the graphs and status down with it —
|
|
41
|
-
// WARN + mount everything else
|
|
42
42
|
try {
|
|
43
43
|
kit = await loadKit(root)
|
|
44
|
-
studio = await studioRoutes({ kit, root, page: studioPage })
|
|
45
44
|
} catch (e) {
|
|
46
|
-
kit
|
|
47
|
-
io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
|
|
45
|
+
io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
|
|
48
46
|
}
|
|
49
47
|
}
|
|
48
|
+
const emptyKit: ResolvedKit = { kinds: [], workspace: '.rv-assets/out' }
|
|
49
|
+
let studio: StudioRoutes | null = null
|
|
50
|
+
try {
|
|
51
|
+
studio = await studioRoutes({ kit: kit ?? emptyKit, root })
|
|
52
|
+
} catch (e) {
|
|
53
|
+
io.print(`WARN media API not mounted: ${(e as Error).message}`)
|
|
54
|
+
}
|
|
50
55
|
const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
56
|
+
const allCollections = () => [
|
|
57
|
+
...buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion }),
|
|
58
|
+
graphsCollection(root, { customWiki: c.serve?.wikiGraph != null, agentIndex: c.serve?.agentIndex }),
|
|
59
|
+
]
|
|
60
|
+
const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
|
|
51
61
|
return {
|
|
52
62
|
root,
|
|
53
63
|
name: c.name,
|
|
54
|
-
port
|
|
64
|
+
port,
|
|
55
65
|
wikiGraph,
|
|
56
66
|
agentGraph,
|
|
57
67
|
studio,
|
|
58
68
|
checkCmd: [process.execPath, cli, 'check'],
|
|
59
|
-
needs: () => projectNeeds(
|
|
69
|
+
needs: () => projectNeeds(allCollections()),
|
|
70
|
+
collections: allCollections,
|
|
60
71
|
asksDir: c.asks?.dir ?? 'asks',
|
|
61
72
|
// RED-only pings while serving; notify command is repo config data.
|
|
62
73
|
// Delivery failure must be LOUD — a silently broken notify path means
|
|
63
74
|
// he assumes no blockers exist
|
|
64
75
|
pingTick: (needs) =>
|
|
65
76
|
pingTick(root, c.asks?.dir ?? 'asks', needs, {
|
|
77
|
+
link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}/?f=needs` : undefined,
|
|
66
78
|
run: (cmd) => {
|
|
67
79
|
const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
68
80
|
void p.exited.then(async (code) => {
|
|
@@ -95,6 +107,8 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
95
107
|
const base = `http://localhost:${hub.server.port}`
|
|
96
108
|
io.print(`ok: ${c.name} hub → ${base}`)
|
|
97
109
|
for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
|
|
110
|
+
// the off-machine door (phone, other machines) — publicHost is config
|
|
111
|
+
if (c.serve?.publicHost != null) io.print(`ok: public → http://${c.serve.publicHost}:${hub.server.port}/`)
|
|
98
112
|
await new Promise(() => {}) // the hub owns the process — never resolve
|
|
99
113
|
return 0
|
|
100
114
|
} catch (e) {
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
// shipkit serve — ONE
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// run-serve.ts's job.
|
|
1
|
+
// shipkit serve — the repo's ONE Bun.serve, serving the ONE page (his
|
|
2
|
+
// rules 2026-07-23: one server per repo, one surface, mobx+react). `/` is
|
|
3
|
+
// the hub app (Bun HTML import — media studio + asks + creds + posts +
|
|
4
|
+
// health + graphs as sections); the studio route table mounts as the media
|
|
5
|
+
// API under /media/*; graph canvases stay as drill-down pages. Route logic
|
|
6
|
+
// only — resolving WHAT to mount from repo.config is run-serve.ts's job.
|
|
7
7
|
import { existsSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
|
|
10
10
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
11
11
|
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
12
|
-
import { appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
|
|
13
|
-
import type { NeedItem } from '../collections/collection-types.ts'
|
|
14
|
-
import { withRail } from './hub-pages.ts'
|
|
15
|
-
// the hub UI is a Bun-native React app (HTML import, no vite, no build) —
|
|
16
|
-
// build/webapp doctrine; legacy pages (graphs, studio SPA) keep the
|
|
17
|
-
// server-injected rail until they migrate
|
|
12
|
+
import { appendAskRemark, appendAssetNote, appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
|
|
13
|
+
import type { Collection, Instance, NeedItem } from '../collections/collection-types.ts'
|
|
18
14
|
import hubIndex from './hub-app/index.html'
|
|
19
15
|
|
|
20
16
|
export type HubGraph = { title: string; load: () => MdGraph }
|
|
@@ -27,13 +23,17 @@ export type HubDeps = {
|
|
|
27
23
|
wikiGraph: HubGraph
|
|
28
24
|
/** null = no agent/ dir */
|
|
29
25
|
agentGraph: HubGraph | null
|
|
30
|
-
/**
|
|
26
|
+
/** the media API (mounted under /media/*); null only if the route
|
|
27
|
+
* table failed to build — the page shows the WARN, never a second page */
|
|
31
28
|
studio: StudioRoutes | null
|
|
32
|
-
/** argv shelling the check run streamed by /
|
|
29
|
+
/** argv shelling the check run streamed by /api/check/run (test seam) */
|
|
33
30
|
checkCmd: string[]
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
31
|
+
/** every human-action item, red/yellow, gathered per request from the
|
|
32
|
+
* collection providers (run-serve builds it) */
|
|
36
33
|
needs: () => Promise<NeedItem[]>
|
|
34
|
+
/** BROWSE-ALL: the collections with every instance incl. healthy —
|
|
35
|
+
* the page shows everything that exists (his trust rule) */
|
|
36
|
+
collections: () => Collection[]
|
|
37
37
|
/** asks dir (repo-root-relative) for the act-in-place ask verbs */
|
|
38
38
|
asksDir: string
|
|
39
39
|
/** RED-only ping tick, called on an interval while serving (null = off) */
|
|
@@ -41,28 +41,27 @@ export type HubDeps = {
|
|
|
41
41
|
}
|
|
42
42
|
export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
|
|
43
43
|
|
|
44
|
-
// the toolbox as data —
|
|
45
|
-
//
|
|
46
|
-
export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'
|
|
47
|
-
const tools: HubTool[] = [{ path: '/
|
|
44
|
+
// the toolbox as data — stdout ok: lines and /hub.json render THIS list,
|
|
45
|
+
// so they can never disagree
|
|
46
|
+
export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'>): HubTool[] {
|
|
47
|
+
const tools: HubTool[] = [{ path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' }]
|
|
48
|
+
tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
|
|
48
49
|
if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
|
|
49
|
-
if (d.studio != null) tools.push({ path: '/assets/', label: 'assets studio', desc: 'roll, curate, pick, ship' })
|
|
50
|
-
tools.push({ path: '/status', label: 'status', desc: 'STATUS.md + run check' })
|
|
51
50
|
return tools
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
// drill-down pages (graph canvases) get one fixed home link back to THE page
|
|
54
|
+
const HOME_LINK = `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
|
|
55
|
+
background:#17140f;border:1px solid #332b22;border-radius:9px;padding:5px 9px}</style><a class=skhome href="/" title="hub — THE page">⌂</a>`
|
|
56
|
+
const withHome = (html: string): string => html.replace(/<body([^>]*)>/, `<body$1>${HOME_LINK}`)
|
|
57
|
+
|
|
54
58
|
export function serveHub(d: HubDeps): Hub {
|
|
55
59
|
const tools = hubTools(d)
|
|
56
|
-
// every HTML page carries the rail — one keystroke between surfaces;
|
|
57
|
-
// the red dot self-fetches client-side (no collector on passive navs)
|
|
58
|
-
const shell = (s: string, active: string): Response =>
|
|
59
|
-
new Response(withRail(s, { tools, active }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
60
60
|
const server: ReturnType<typeof Bun.serve> = Bun.serve({
|
|
61
61
|
hostname: '127.0.0.1',
|
|
62
62
|
port: d.port,
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
routes: { '/': hubIndex, '/status': hubIndex },
|
|
63
|
+
// `/` IS the page — the react app; there is no other surface
|
|
64
|
+
routes: { '/': hubIndex },
|
|
66
65
|
async fetch(req): Promise<Response> {
|
|
67
66
|
const path = new URL(req.url).pathname
|
|
68
67
|
// identity probe: lets a second launch tell "same repo, another
|
|
@@ -72,9 +71,22 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
72
71
|
const needs = await d.needs().catch((e: Error) => [{ collection: 'hub', id: 'hub:broken', text: `needs projection broke: ${e.message}`, severity: 'red' as const }])
|
|
73
72
|
return Response.json({ needs })
|
|
74
73
|
}
|
|
74
|
+
if (path === '/api/collections.json') {
|
|
75
|
+
// browse-all: every instance of every collection, healthy
|
|
76
|
+
// included — failure-isolated per collection
|
|
77
|
+
const out: { id: string; title: string; media: string; instances: Instance[] }[] = []
|
|
78
|
+
for (const col of d.collections()) {
|
|
79
|
+
try {
|
|
80
|
+
out.push({ id: col.id, title: col.title, media: col.media, instances: await col.list() })
|
|
81
|
+
} catch (e) {
|
|
82
|
+
out.push({ id: col.id, title: col.title, media: col.media, instances: [{ id: `${col.id}:broken`, text: `collector broke: ${(e as Error).message}`, severity: 'red' }] })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Response.json({ collections: out })
|
|
86
|
+
}
|
|
75
87
|
if (path.startsWith('/api/act/') && req.method === 'POST') {
|
|
76
88
|
// act-in-place verbs — every mutation hits a git-tracked truth
|
|
77
|
-
// file; errors surface as 400 text the
|
|
89
|
+
// file; errors surface as 400 text the card shows verbatim
|
|
78
90
|
try {
|
|
79
91
|
const b = (await req.json()) as Record<string, string>
|
|
80
92
|
if (path === '/api/act/ask-done') askDone(d.root, d.asksDir, b.file ?? '')
|
|
@@ -86,6 +98,16 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
86
98
|
markPosted(d.root, b.channel ?? '', b.file ?? '', b.url ?? '', rating)
|
|
87
99
|
}
|
|
88
100
|
else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
|
|
101
|
+
else if (path === '/api/act/remark') {
|
|
102
|
+
// ONE remark verb, routed by collection — his words
|
|
103
|
+
// land in the item's own truth file
|
|
104
|
+
const text = (b.text ?? '').trim()
|
|
105
|
+
if (text === '') return new Response('empty remark', { status: 400 })
|
|
106
|
+
if (b.kind === 'ask') appendAskRemark(d.root, d.asksDir, b.file ?? '', text)
|
|
107
|
+
else if (b.kind === 'media') appendAssetNote(d.root, b.id ?? '', text)
|
|
108
|
+
else if (b.kind === 'post') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', text)
|
|
109
|
+
else return new Response('unknown remark kind', { status: 400 })
|
|
110
|
+
}
|
|
89
111
|
else return new Response('unknown act', { status: 404 })
|
|
90
112
|
return Response.json({ ok: true })
|
|
91
113
|
} catch (e) {
|
|
@@ -96,28 +118,21 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
96
118
|
const p = join(d.root, 'STATUS.md')
|
|
97
119
|
return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
|
|
98
120
|
}
|
|
99
|
-
if (path === '/
|
|
100
|
-
if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
101
|
-
if (d.agentGraph != null && path === '/graph/agent') return shell(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }), path)
|
|
102
|
-
if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
|
|
103
|
-
if (path === '/status/run' && req.method === 'POST') {
|
|
121
|
+
if (path === '/api/check/run' && req.method === 'POST') {
|
|
104
122
|
// stream wire lines as the check emits them; sh merges stderr
|
|
105
123
|
// so FAIL lines reach the page, not just the terminal
|
|
106
124
|
const proc = Bun.spawn(['sh', '-c', 'exec "$0" "$@" 2>&1', ...d.checkCmd], { cwd: d.root, stdout: 'pipe', stderr: 'ignore' })
|
|
107
125
|
return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
108
126
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
|
|
119
|
-
}
|
|
120
|
-
return new Response('not found', { status: 404 })
|
|
127
|
+
const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
128
|
+
if (path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
|
|
129
|
+
if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
130
|
+
if (d.agentGraph != null && path === '/graph/agent') return page(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
|
|
131
|
+
if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
|
|
132
|
+
// the media API namespace — the studio route table, ONE prefix strip
|
|
133
|
+
if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
|
|
134
|
+
// stale bookmarks (/assets/, /status, …) land here — name the way home
|
|
135
|
+
return new Response('not found — the hub page is at /', { status: 404 })
|
|
121
136
|
},
|
|
122
137
|
})
|
|
123
138
|
// RED-only pings ride the serving process: one tick now (state file
|
package/src/shipkit.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
|
|
2
2
|
// everything else is the machinery behind the shipkit CLI.
|
|
3
|
-
export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask
|
|
3
|
+
export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask } from './repo-config.ts'
|
|
4
4
|
export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './repo-config.ts'
|
|
5
|
+
export { contractsConfigFrom, activeContracts, applyContractsResult } from './contracts/apply-contracts.ts'
|
|
5
6
|
// the hub (`shipkit serve`): one server, one port, every tool a click away
|
|
6
7
|
export { serveHub, hubTools } from './serve/serve-hub.ts'
|
|
7
8
|
export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
|
|
8
9
|
export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
|
|
9
|
-
export { withRail } from './serve/hub-pages.ts'
|
|
10
10
|
export { projectNeeds, redCount } from './collections/collection-types.ts'
|
|
11
11
|
export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
|
|
12
12
|
export { buildCollections } from './collections/providers.ts'
|
|
@@ -27,6 +27,11 @@ export { writeStatus } from './core/write-status.ts'
|
|
|
27
27
|
export type { StatusInput, StatusOutcome } from './core/write-status.ts'
|
|
28
28
|
export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
|
|
29
29
|
export type { Line } from './core/wire.ts'
|
|
30
|
+
// the SK rule registry — stable-id invariants `shipkit check` enforces;
|
|
31
|
+
// consumers (e.g. a wiki lint verifying mentioned ids exist) read RULES
|
|
32
|
+
export { RULES, ruleById } from './rules/rule-registry.ts'
|
|
33
|
+
export { tagRuleLines } from './rules/rule.ts'
|
|
34
|
+
export type { Rule, RuleEnv, FixResult } from './rules/rule.ts'
|
|
30
35
|
export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
|
|
31
36
|
export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
|
|
32
37
|
export { defineContract, validateManifest, file, png, glob, md, dir, custom, script, fileContains, dirExists } from './contracts/contract-types.ts'
|
|
@@ -41,9 +46,8 @@ export { defineKit, defineKind, comfy, canvas, manual, gemini, mediaOf, MEDIA_EX
|
|
|
41
46
|
export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, GeminiGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
42
47
|
export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
|
|
43
48
|
export type { Brand } from './assets/kits/identity-kit.ts'
|
|
44
|
-
export {
|
|
45
|
-
export type {
|
|
46
|
-
export { page as studioPage } from './assets/server/studio-page.ts'
|
|
49
|
+
export { studioRoutes } from './assets/server/studio-server.ts'
|
|
50
|
+
export type { StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
|
|
47
51
|
export { StudioQueue } from './assets/server/studio-queue.ts'
|
|
48
52
|
export type { Job, QueueState } from './assets/server/studio-queue.ts'
|
|
49
53
|
export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'
|