rvlib-shipkit 0.10.0 → 0.13.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 +204 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +42 -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_Needs.ts +47 -0
- 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 +7 -2
- package/src/collections/actions.ts +85 -0
- package/src/collections/collection-types.ts +65 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +72 -0
- package/src/collections/posts.ts +125 -0
- package/src/collections/providers.ts +275 -0
- package/src/contracts/apply-contracts.ts +47 -0
- 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 +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +26 -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/rule-registry.ts +32 -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 +240 -127
- 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 -35
- 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 +46 -91
- package/src/serve/serve-hub.ts +99 -49
- package/src/shipkit.ts +13 -6
- 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 -45
|
@@ -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
|
@@ -5,20 +5,17 @@
|
|
|
5
5
|
// kit.ts loads. Port: --port > serve.port > a deterministic house-range
|
|
6
6
|
// default (4800-4979, hashed from the repo name — stable across launches;
|
|
7
7
|
// pin serve.port when two repos collide).
|
|
8
|
-
import { existsSync,
|
|
8
|
+
import { existsSync, readFileSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
|
-
import { loadKit,
|
|
12
|
-
import { isStale } from '../assets/gen/derived.ts'
|
|
13
|
-
import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
|
|
14
|
-
import { healIdentity } from '../assets/run-assets.ts'
|
|
15
|
-
import { page as studioPage } from '../assets/server/studio-page.ts'
|
|
11
|
+
import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
|
|
16
12
|
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
13
|
+
import { projectNeeds } from '../collections/collection-types.ts'
|
|
14
|
+
import { graphsCollection } from '../collections/graphs.ts'
|
|
15
|
+
import { pingTick } from '../collections/pings.ts'
|
|
16
|
+
import { buildCollections } from '../collections/providers.ts'
|
|
17
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
20
18
|
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
21
|
-
import type { AttentionSection } from './hub-pages.ts'
|
|
22
19
|
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
23
20
|
|
|
24
21
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
@@ -37,29 +34,55 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
37
34
|
const agentGraph = existsSync(agentRoot)
|
|
38
35
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
39
36
|
: null
|
|
40
|
-
|
|
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.
|
|
41
40
|
let kit: ResolvedKit | null = null
|
|
42
41
|
if (existsSync(join(root, '.rv-assets/kit.ts'))) {
|
|
43
|
-
// a broken kit must not take the graphs and status down with it —
|
|
44
|
-
// WARN + mount everything else
|
|
45
42
|
try {
|
|
46
43
|
kit = await loadKit(root)
|
|
47
|
-
studio = await studioRoutes({ kit, root, page: studioPage })
|
|
48
44
|
} catch (e) {
|
|
49
|
-
kit
|
|
50
|
-
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}`)
|
|
51
46
|
}
|
|
52
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
|
+
}
|
|
53
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)
|
|
54
61
|
return {
|
|
55
62
|
root,
|
|
56
63
|
name: c.name,
|
|
57
|
-
port
|
|
64
|
+
port,
|
|
58
65
|
wikiGraph,
|
|
59
66
|
agentGraph,
|
|
60
67
|
studio,
|
|
61
68
|
checkCmd: [process.execPath, cli, 'check'],
|
|
62
|
-
|
|
69
|
+
needs: () => projectNeeds(allCollections()),
|
|
70
|
+
collections: allCollections,
|
|
71
|
+
asksDir: c.asks?.dir ?? 'asks',
|
|
72
|
+
// RED-only pings while serving; notify command is repo config data.
|
|
73
|
+
// Delivery failure must be LOUD — a silently broken notify path means
|
|
74
|
+
// he assumes no blockers exist
|
|
75
|
+
pingTick: (needs) =>
|
|
76
|
+
pingTick(root, c.asks?.dir ?? 'asks', needs, {
|
|
77
|
+
link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}/?f=needs` : undefined,
|
|
78
|
+
run: (cmd) => {
|
|
79
|
+
const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
80
|
+
void p.exited.then(async (code) => {
|
|
81
|
+
if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
notifyCommand: c.notifyCommand,
|
|
85
|
+
}),
|
|
63
86
|
}
|
|
64
87
|
}
|
|
65
88
|
|
|
@@ -72,79 +95,9 @@ const libVersion = (): string => {
|
|
|
72
95
|
}
|
|
73
96
|
const BOOT_VERSION = libVersion()
|
|
74
97
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const guard = async (title: string, tone: 'red' | 'warn', body: () => Promise<AttentionSection['lines']>): Promise<AttentionSection> => {
|
|
79
|
-
try { return { title, tone, lines: await body() } } catch (e) { return { title, tone: 'red', lines: [{ text: `collector broke: ${(e as Error).message}` }] } }
|
|
80
|
-
}
|
|
81
|
-
return async () => {
|
|
82
|
-
const sections = await Promise.all([
|
|
83
|
-
guard('hub process', 'red', async () => {
|
|
84
|
-
const now = libVersion()
|
|
85
|
-
if (now === BOOT_VERSION) return []
|
|
86
|
-
return [{ text: `hub is running shipkit ${BOOT_VERSION} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` }]
|
|
87
|
-
}),
|
|
88
|
-
guard('failing checks', 'red', async () => {
|
|
89
|
-
const p = join(root, 'STATUS.md')
|
|
90
|
-
if (!existsSync(p)) return []
|
|
91
|
-
// a cred FAIL whose ask already sits in the asks section is
|
|
92
|
-
// ONE action shown twice — the ask (his hands) wins
|
|
93
|
-
const askDir = join(root, c.asks?.dir ?? 'asks')
|
|
94
|
-
const askText = existsSync(askDir) ? openAsks(askDir).map((a) => a.title.toLowerCase()).join(' ') : ''
|
|
95
|
-
return (await Bun.file(p).text()).split('\n')
|
|
96
|
-
.filter((l) => l.trimStart().startsWith('FAIL'))
|
|
97
|
-
.filter((l) => {
|
|
98
|
-
const cred = l.match(/cred rv\/([a-z0-9-]+)\/[a-z0-9-]+ missing/)?.[1]
|
|
99
|
-
return cred == null || !askText.includes(cred)
|
|
100
|
-
})
|
|
101
|
-
.map((text) => ({ text, href: '/status' }))
|
|
102
|
-
}),
|
|
103
|
-
guard('asks — waiting on you', 'red', async () => {
|
|
104
|
-
const dir = join(root, c.asks?.dir ?? 'asks')
|
|
105
|
-
if (!existsSync(dir)) return [] // read path must not mint an asks/ dir
|
|
106
|
-
return openAsks(dir).map((a) => ({ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}` }))
|
|
107
|
-
}),
|
|
108
|
-
guard('assets', 'red', async () => {
|
|
109
|
-
// self-heal first: identity re-renders itself on brand.ts
|
|
110
|
-
// drift; only pick-blocked (taste) surfaces below
|
|
111
|
-
const idn = await healIdentity(root)
|
|
112
|
-
const lines: AttentionSection['lines'] = []
|
|
113
|
-
if (idn.state === 'blocked') lines.push({ text: `identity — ${idn.detail ?? 'blocked'}`, href: '/assets/#identity-logo' })
|
|
114
|
-
if (kit == null) return lines
|
|
115
|
-
for (const f of allAssets(kit)) {
|
|
116
|
-
const dir = assetDir(join(root, kit.workspace), f.asset.id)
|
|
117
|
-
const rolls = listRolls(dir)
|
|
118
|
-
const manual = f.kind.generate.backend === 'manual'
|
|
119
|
-
if (manual && rolls.length === 0) lines.push({ text: `${f.asset.id} — manual, nothing uploaded`, href: `/assets/#${f.asset.id}` })
|
|
120
|
-
else if (rolls.length > 0 && (await readPick(dir)).pick == null) lines.push({ text: `${f.asset.id} — ${rolls.length} roll(s), none picked`, href: `/assets/#${f.asset.id}` })
|
|
121
|
-
else if (await isStale(kit, join(root, kit.workspace), f)) lines.push({ text: `${f.asset.id} — stale (a parent was re-picked)`, href: `/assets/#${f.asset.id}` })
|
|
122
|
-
}
|
|
123
|
-
return lines
|
|
124
|
-
}),
|
|
125
|
-
guard('contracts', 'red', async () => {
|
|
126
|
-
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
|
|
127
|
-
return r.lines.filter((l) => l.kind === 'fail').map((l) => ({ text: l.text }))
|
|
128
|
-
}),
|
|
129
|
-
guard('contract warnings', 'warn', async () => {
|
|
130
|
-
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
|
|
131
|
-
return r.lines.filter((l) => l.kind === 'warn').map((l) => ({ text: l.text }))
|
|
132
|
-
}),
|
|
133
|
-
guard('posts to write', 'warn', async () => {
|
|
134
|
-
const social = join(root, '.rv-social')
|
|
135
|
-
if (!existsSync(social)) return []
|
|
136
|
-
const lines: AttentionSection['lines'] = []
|
|
137
|
-
for (const ch of readdirSync(social)) {
|
|
138
|
-
const d = join(social, ch)
|
|
139
|
-
if (!statSync(d).isDirectory()) continue
|
|
140
|
-
for (const f of readdirSync(d).filter((x) => x.endsWith('.md'))) lines.push({ text: `${ch}/${f}` })
|
|
141
|
-
}
|
|
142
|
-
return lines
|
|
143
|
-
}),
|
|
144
|
-
])
|
|
145
|
-
return sections
|
|
146
|
-
}
|
|
147
|
-
}
|
|
98
|
+
// the / dashboard's data now comes from the collection providers
|
|
99
|
+
// (src/collections/providers.ts) — the old attentionCollector was DELETED
|
|
100
|
+
// when they landed; never reintroduce a parallel projection.
|
|
148
101
|
|
|
149
102
|
export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
|
|
150
103
|
const c = await loadRepoConfig(root)
|
|
@@ -154,6 +107,8 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
154
107
|
const base = `http://localhost:${hub.server.port}`
|
|
155
108
|
io.print(`ok: ${c.name} hub → ${base}`)
|
|
156
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}/`)
|
|
157
112
|
await new Promise(() => {}) // the hub owns the process — never resolve
|
|
158
113
|
return 0
|
|
159
114
|
} catch (e) {
|