rvlib-shipkit 0.6.0 → 0.8.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 +52 -1
- package/package.json +3 -2
- package/src/assets/gen/derived.ts +83 -0
- package/src/assets/gen/gemini-gen.ts +77 -0
- package/src/assets/gen/generate-roll.ts +12 -1
- package/src/assets/kit-config.ts +25 -2
- package/src/assets/run-assets.ts +69 -28
- package/src/assets/server/studio-page.ts +3 -2
- package/src/assets/server/studio-server.ts +31 -6
- package/src/cli/cmd_Mv.ts +34 -0
- package/src/cli.ts +2 -0
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/social.contract.ts +1 -1
- package/src/core/package-json-check.ts +1 -0
- package/src/init-repo.ts +1 -1
- package/src/mv/mv-md.ts +90 -0
- package/src/serve/hub-app/hub-app.tsx +156 -0
- package/src/serve/hub-app/hub.css +41 -0
- package/src/serve/hub-app/index.html +13 -0
- package/src/serve/hub-pages.ts +39 -78
- package/src/serve/run-serve.ts +97 -4
- package/src/serve/serve-hub.ts +30 -9
- package/src/shipkit.ts +6 -3
- package/src/tui/run-tui.ts +1 -1
- package/src/tui/tui-store.ts +1 -1
|
@@ -5,7 +5,7 @@ import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/
|
|
|
5
5
|
export default defineContract({
|
|
6
6
|
venue: 'discord',
|
|
7
7
|
dir: '.rv-discord',
|
|
8
|
-
owner: 'distribute/
|
|
8
|
+
owner: 'distribute/post-publicly.md',
|
|
9
9
|
appliesTo: 'declared',
|
|
10
10
|
detect: [dirExists('.rv-discord')],
|
|
11
11
|
artifacts: [
|
|
@@ -6,7 +6,7 @@ import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/con
|
|
|
6
6
|
export default defineContract({
|
|
7
7
|
venue: 'identity',
|
|
8
8
|
dir: '.rv-identity',
|
|
9
|
-
owner: 'build/assets.md',
|
|
9
|
+
owner: 'build/generate-assets.md',
|
|
10
10
|
appliesTo: 'always',
|
|
11
11
|
gate: 'public-surface',
|
|
12
12
|
detect: [],
|
|
@@ -5,7 +5,7 @@ import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/c
|
|
|
5
5
|
export default defineContract({
|
|
6
6
|
venue: 'social',
|
|
7
7
|
dir: '.rv-social',
|
|
8
|
-
owner: 'distribute/
|
|
8
|
+
owner: 'distribute/post-publicly.md',
|
|
9
9
|
appliesTo: 'declared',
|
|
10
10
|
detect: [dirExists('.rv-social')],
|
|
11
11
|
artifacts: [
|
|
@@ -68,6 +68,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
|
|
|
68
68
|
{ name: 'contracts', why: 'shipkit repo' },
|
|
69
69
|
{ name: 'tui', why: 'shipkit repo' },
|
|
70
70
|
{ name: 'hub', why: 'shipkit repo' },
|
|
71
|
+
{ name: 'mv', why: 'shipkit repo' },
|
|
71
72
|
]
|
|
72
73
|
if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
|
|
73
74
|
if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
|
package/src/init-repo.ts
CHANGED
|
@@ -42,7 +42,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
42
42
|
let added = 0
|
|
43
43
|
// canonical script NAME → shipkit command (hub runs `shipkit serve`) —
|
|
44
44
|
// vocabulary owned by meta/rv-conventions/package-json.md
|
|
45
|
-
const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve' } as const
|
|
45
|
+
const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
|
|
46
46
|
for (const [k, cmd] of Object.entries(CANONICAL)) {
|
|
47
47
|
const v = run(cmd)
|
|
48
48
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
package/src/mv/mv-md.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// shipkit mv — rename an md page the AGILE way: one command moves the .md,
|
|
2
|
+
// its sibling asset dir and <name>.check.ts, then sweeps EVERY tracked text
|
|
3
|
+
// file for refs (full path, docs-root-relative id, check filename). Dry by
|
|
4
|
+
// default; --write applies; --commit adds one conventional commit (the
|
|
5
|
+
// repo's pre-commit hook is the gate). If package.json has a `mv:after`
|
|
6
|
+
// script it runs after the moves (repo-specific re-stamps). TS module moves
|
|
7
|
+
// are a different concern (a TS-aware renamer owns import graphs).
|
|
8
|
+
import { execSync } from 'child_process'
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
10
|
+
import { basename, dirname, join } from 'path'
|
|
11
|
+
|
|
12
|
+
export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
13
|
+
export type MvOpts = { write?: boolean; commit?: boolean }
|
|
14
|
+
|
|
15
|
+
const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
|
|
16
|
+
|
|
17
|
+
export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
|
|
18
|
+
const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
|
|
19
|
+
if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
|
|
20
|
+
if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
|
|
21
|
+
if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
|
|
22
|
+
if (dirname(oldRel) !== dirname(newRel)) return die('cross-directory moves not supported yet, same dir only')
|
|
23
|
+
|
|
24
|
+
const write = o.write === true || o.commit === true
|
|
25
|
+
const oldName = basename(oldRel, '.md')
|
|
26
|
+
const newName = basename(newRel, '.md')
|
|
27
|
+
const oldBase = oldRel.slice(0, -3) // full path sans .md — the sibling dir
|
|
28
|
+
const newBase = newRel.slice(0, -3)
|
|
29
|
+
// docs-root-relative id: refs inside a wiki usually omit the top dir
|
|
30
|
+
// (`build/state`, not `wiki/build/state`) — sweep both forms
|
|
31
|
+
const tail = (p: string): string | null => { const seg = p.split('/'); return seg.length >= 3 ? seg.slice(1).join('/') : null }
|
|
32
|
+
const oldId = tail(oldBase)
|
|
33
|
+
const newId = tail(newBase)
|
|
34
|
+
|
|
35
|
+
// longest-first so `wiki/x/state.md` beats `x/state` beats bare tokens
|
|
36
|
+
const pairs: [string, string][] = [
|
|
37
|
+
[`${oldBase}.md`, `${newBase}.md`],
|
|
38
|
+
...(oldId != null ? [[`${oldId}.md`, `${newId}.md`] as [string, string]] : []),
|
|
39
|
+
// probe file is named after its page; DIR-qualified so another
|
|
40
|
+
// page's same-named check.ts in a different dir never gets rewritten
|
|
41
|
+
[`${oldBase}/${oldName}.check.ts`, `${newBase}/${newName}.check.ts`],
|
|
42
|
+
...(oldId != null ? [[`${oldId}/${oldName}.check.ts`, `${newId}/${newName}.check.ts`] as [string, string]] : []),
|
|
43
|
+
[`${oldBase}/`, `${newBase}/`],
|
|
44
|
+
...(oldId != null ? [[`${oldId}/`, `${newId}/`] as [string, string]] : []),
|
|
45
|
+
[oldBase, newBase],
|
|
46
|
+
...(oldId != null ? [[oldId, newId!] as [string, string]] : []),
|
|
47
|
+
]
|
|
48
|
+
const bound = (t: string): RegExp =>
|
|
49
|
+
new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
|
|
50
|
+
|
|
51
|
+
const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
|
|
52
|
+
.filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
|
|
53
|
+
let touched = 0
|
|
54
|
+
for (const f of tracked) {
|
|
55
|
+
const p = join(root, f)
|
|
56
|
+
if (!existsSync(p)) continue
|
|
57
|
+
const before = readFileSync(p, 'utf8')
|
|
58
|
+
let after = before
|
|
59
|
+
for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
|
|
60
|
+
if (after !== before) {
|
|
61
|
+
io.print(` ${f}`)
|
|
62
|
+
if (write) writeFileSync(p, after)
|
|
63
|
+
touched++
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
|
|
67
|
+
if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
|
|
68
|
+
|
|
69
|
+
// moves AFTER the sweep (the sweep rewrote files at their old paths)
|
|
70
|
+
sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
|
|
71
|
+
if (existsSync(join(root, oldBase))) {
|
|
72
|
+
sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
|
|
73
|
+
const oldCheck = join(newBase, `${oldName}.check.ts`)
|
|
74
|
+
if (existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure
|
|
78
|
+
const pkg = existsSync(join(root, 'package.json')) ? (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }) : {}
|
|
79
|
+
if (pkg.scripts?.['mv:after'] != null) {
|
|
80
|
+
try { execSync('bun run mv:after', { cwd: root, stdio: 'inherit' }) } catch { return die('mv:after failed, fix before committing') }
|
|
81
|
+
}
|
|
82
|
+
if (o.commit === true) {
|
|
83
|
+
sh('git add -A', root)
|
|
84
|
+
try {
|
|
85
|
+
execSync(`git commit -m ${JSON.stringify(`docs: rename ${oldBase} → ${newBase}`)}`, { cwd: root, stdio: 'inherit' })
|
|
86
|
+
} catch { return die('commit failed (pre-commit gate?). The rename is applied, fix and commit yourself') }
|
|
87
|
+
}
|
|
88
|
+
io.print(`ok: ${oldBase} → ${newBase}${o.commit === true ? ' (committed)' : ''}`)
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The hub's React app (Bun.serve HTML import — no vite, no build step).
|
|
2
|
+
// Two views routed by pathname: / = attention dashboard (everything red),
|
|
3
|
+
// /status = STATUS.md + streamed check runs. The rail lists every mounted
|
|
4
|
+
// tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
|
|
5
|
+
// injected server-side until they migrate.
|
|
6
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { StrictMode } from 'react'
|
|
9
|
+
import { createRoot } from 'react-dom/client'
|
|
10
|
+
|
|
11
|
+
type HubTool = { path: string; label: string; desc: string }
|
|
12
|
+
type AttentionLine = { text: string; href?: string }
|
|
13
|
+
type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
|
|
14
|
+
|
|
15
|
+
const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
|
|
16
|
+
|
|
17
|
+
class HubStore {
|
|
18
|
+
name = ''
|
|
19
|
+
tools: HubTool[] = []
|
|
20
|
+
sections: AttentionSection[] | null = null
|
|
21
|
+
statusMd: string | null = null
|
|
22
|
+
running = false
|
|
23
|
+
note = ''
|
|
24
|
+
error: string | null = null
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
makeAutoObservable(this)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get redCount(): number {
|
|
31
|
+
return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async load(): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
|
|
37
|
+
const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
|
|
38
|
+
runInAction(() => {
|
|
39
|
+
this.name = hub.name
|
|
40
|
+
this.tools = hub.tools
|
|
41
|
+
this.sections = att.sections
|
|
42
|
+
})
|
|
43
|
+
if (location.pathname === '/status') {
|
|
44
|
+
const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
|
|
45
|
+
runInAction(() => { this.statusMd = s.statusMd })
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async runCheck(): Promise<void> {
|
|
53
|
+
this.running = true
|
|
54
|
+
this.note = 'running…'
|
|
55
|
+
let text = ''
|
|
56
|
+
try {
|
|
57
|
+
const r = await fetch('/status/run', { method: 'POST' })
|
|
58
|
+
const rd = r.body!.getReader()
|
|
59
|
+
const dec = new TextDecoder()
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { done, value } = await rd.read()
|
|
62
|
+
if (done) break
|
|
63
|
+
text += dec.decode(value, { stream: true })
|
|
64
|
+
runInAction(() => { this.statusMd = text })
|
|
65
|
+
}
|
|
66
|
+
runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
|
|
67
|
+
void this.load() // red counts moved — refresh the dashboard data
|
|
68
|
+
} catch (e) {
|
|
69
|
+
runInAction(() => {
|
|
70
|
+
this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
|
|
71
|
+
this.note = 'stream broke'
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
runInAction(() => { this.running = false })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const store = new HubStore()
|
|
79
|
+
void store.load()
|
|
80
|
+
|
|
81
|
+
const Rail = observer(() => {
|
|
82
|
+
const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
|
|
83
|
+
return (
|
|
84
|
+
<nav className="skrail">
|
|
85
|
+
{items.map((t) => {
|
|
86
|
+
const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
|
|
87
|
+
return (
|
|
88
|
+
<a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
|
|
89
|
+
{RAIL_ICONS[t.path] ?? '·'}
|
|
90
|
+
{t.path === '/' && store.redCount > 0 ? <i /> : null}
|
|
91
|
+
</a>
|
|
92
|
+
)
|
|
93
|
+
})}
|
|
94
|
+
</nav>
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const Attention = observer(() => {
|
|
99
|
+
const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
|
|
100
|
+
return (
|
|
101
|
+
<div className="wrap">
|
|
102
|
+
<div className="crumb">shipkit hub · attention</div>
|
|
103
|
+
<h1>{store.name}<small>everything that needs you, one page</small></h1>
|
|
104
|
+
{store.sections == null ? <div className="sub">loading…</div>
|
|
105
|
+
: live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
|
|
106
|
+
: live.map((s) => (
|
|
107
|
+
<section key={s.title} className={s.tone}>
|
|
108
|
+
<h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
|
|
109
|
+
{s.lines.map((l, i) => (
|
|
110
|
+
<div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
|
|
111
|
+
))}
|
|
112
|
+
</section>
|
|
113
|
+
))}
|
|
114
|
+
<div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const wireClass = (l: string): string => {
|
|
120
|
+
const t = l.trimStart()
|
|
121
|
+
if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
|
|
122
|
+
if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
|
|
123
|
+
if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
|
|
124
|
+
if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const Status = observer(() => (
|
|
129
|
+
<div className="wrap">
|
|
130
|
+
<div className="crumb">shipkit hub · status</div>
|
|
131
|
+
<h1>{store.name}<small>STATUS.md</small></h1>
|
|
132
|
+
<div className="bar">
|
|
133
|
+
<button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
|
|
134
|
+
<span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<pre>
|
|
137
|
+
{(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
|
|
138
|
+
.split('\n')
|
|
139
|
+
.map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
|
|
140
|
+
</pre>
|
|
141
|
+
</div>
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
const App = observer(() => (
|
|
145
|
+
<div className="app">
|
|
146
|
+
<Rail />
|
|
147
|
+
{store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
|
|
148
|
+
: location.pathname === '/status' ? <Status /> : <Attention />}
|
|
149
|
+
</div>
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
createRoot(document.getElementById('root')!).render(
|
|
153
|
+
<StrictMode>
|
|
154
|
+
<App />
|
|
155
|
+
</StrictMode>,
|
|
156
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* house palette — matches the studio/graph family. Plain CSS on purpose:
|
|
2
|
+
a published lib must not impose bunfig plugins (tailwind) on consumers. */
|
|
3
|
+
:root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
|
|
4
|
+
--accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
|
|
5
|
+
*{box-sizing:border-box}
|
|
6
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
|
|
7
|
+
.app{display:flex;min-height:100vh}
|
|
8
|
+
.wrap{max-width:900px;margin:0 auto;padding:34px 26px;flex:1}
|
|
9
|
+
h1{font-size:20px;margin:0}
|
|
10
|
+
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
|
|
11
|
+
.crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
|
|
12
|
+
|
|
13
|
+
.skrail{position:sticky;top:0;align-self:flex-start;height:100vh;width:46px;display:flex;flex-direction:column;
|
|
14
|
+
align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid var(--line);flex:none}
|
|
15
|
+
.skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
|
|
16
|
+
font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
|
|
17
|
+
.skrail a:hover{background:var(--panel2);filter:none;opacity:1}
|
|
18
|
+
.skrail a.on{background:var(--panel2);border-left-color:var(--accent);filter:none;opacity:1}
|
|
19
|
+
.skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:var(--red)}
|
|
20
|
+
|
|
21
|
+
.clear{margin-top:30px;font-size:17px;color:var(--teal)}
|
|
22
|
+
section{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;
|
|
23
|
+
padding:14px 18px;margin-top:16px}
|
|
24
|
+
section.red{border-left-color:var(--red)}
|
|
25
|
+
h2{font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:8px}
|
|
26
|
+
h2 a{color:var(--ink);text-decoration:none}h2 a:hover{color:var(--gold)}
|
|
27
|
+
h2 em{font-style:normal;font-size:12px;color:var(--dim);background:var(--panel2);border-radius:8px;padding:1px 8px}
|
|
28
|
+
.line{font:12.5px/1.7 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
|
|
29
|
+
.line a{color:var(--dim);text-decoration:underline dotted}.line a:hover{color:var(--gold)}
|
|
30
|
+
section.red .line{color:#e8a0a0}
|
|
31
|
+
.sub{margin-top:10px;font-size:12px;color:var(--dim)}
|
|
32
|
+
.sub a{color:var(--dim)}
|
|
33
|
+
|
|
34
|
+
.bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
|
|
35
|
+
button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
|
|
36
|
+
button:disabled{opacity:.45;cursor:default}
|
|
37
|
+
.note{font-size:12px;color:var(--dim)}
|
|
38
|
+
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
|
|
39
|
+
font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
|
|
40
|
+
.ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
|
|
41
|
+
.err{margin-top:26px;color:var(--red)}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>shipkit hub</title>
|
|
7
|
+
<link rel="stylesheet" href="./hub.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./hub-app.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/src/serve/hub-pages.ts
CHANGED
|
@@ -1,84 +1,45 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Shared hub-page plumbing. The hub UI itself is the Bun-native React app
|
|
2
|
+
// in hub-app/ (HTML import, served by serve-hub.ts — build/webapp
|
|
3
|
+
// doctrine). This module keeps what the LEGACY pages (graph pages, studio
|
|
4
|
+
// SPA) still need until they migrate: the server-injected rail, and the
|
|
5
|
+
// attention section types the collector + React app share.
|
|
5
6
|
import type { HubTool } from './serve-hub.ts'
|
|
6
7
|
|
|
7
|
-
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<')
|
|
8
|
+
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"')
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
:
|
|
11
|
-
--accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
|
|
12
|
-
*{box-sizing:border-box}
|
|
13
|
-
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
|
|
14
|
-
.wrap{max-width:860px;margin:0 auto;padding:40px 24px}
|
|
15
|
-
h1{font-size:20px;margin:0}
|
|
16
|
-
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
|
|
17
|
-
.crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
|
|
18
|
-
.crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
|
|
19
|
-
`
|
|
10
|
+
// ── the rail: thin fixed tab bar, injected into legacy hub-served pages ───
|
|
11
|
+
const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
padding:16px;text-decoration:none;color:var(--ink)}
|
|
31
|
-
.card:hover{border-color:var(--accent);background:var(--panel2)}
|
|
32
|
-
.card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
|
|
33
|
-
.card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
|
|
34
|
-
</style></head><body><div class=wrap>
|
|
35
|
-
<div class=crumb>shipkit hub</div>
|
|
36
|
-
<h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
|
|
37
|
-
<div class=cards>
|
|
38
|
-
${cards}
|
|
39
|
-
</div>
|
|
40
|
-
</div></body></html>`
|
|
41
|
-
}
|
|
13
|
+
const RAIL_CSS = `
|
|
14
|
+
.skrail{position:fixed;left:0;top:0;bottom:0;width:46px;z-index:9999;display:flex;flex-direction:column;
|
|
15
|
+
align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid #332b22}
|
|
16
|
+
.skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
|
|
17
|
+
font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
|
|
18
|
+
.skrail a:hover{background:#262019;filter:none;opacity:1}
|
|
19
|
+
.skrail a.on{background:#262019;border-left-color:#e88c28;filter:none;opacity:1}
|
|
20
|
+
.skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05252}
|
|
21
|
+
body{padding-left:46px}`
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
|
|
59
|
-
<pre id=out></pre>
|
|
60
|
-
<script>
|
|
61
|
-
const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
|
|
62
|
-
// wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
|
|
63
|
-
const esc=s=>s.replace(/&/g,'&').replace(/</g,'<')
|
|
64
|
-
const cls=l=>{const t=l.trimStart()
|
|
65
|
-
if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
|
|
66
|
-
if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
|
|
67
|
-
if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
|
|
68
|
-
if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
|
|
69
|
-
return ''}
|
|
70
|
-
const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
|
|
71
|
-
paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
|
|
72
|
-
btn.onclick=async()=>{
|
|
73
|
-
btn.disabled=true;note.textContent='running…';let text=''
|
|
74
|
-
try{
|
|
75
|
-
const r=await fetch('status/run',{method:'POST'})
|
|
76
|
-
const rd=r.body.getReader(),dec=new TextDecoder()
|
|
77
|
-
for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
|
|
78
|
-
note.textContent='done — STATUS.md refreshed on disk'
|
|
79
|
-
}catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
|
|
80
|
-
btn.disabled=false
|
|
81
|
-
}
|
|
82
|
-
</script>
|
|
83
|
-
</div></body></html>`
|
|
23
|
+
/** Inject the rail into a legacy hub-served HTML document (post-<body>,
|
|
24
|
+
* fixed position — the page needs no layout knowledge). The React app
|
|
25
|
+
* renders its own identical rail from /hub.json. */
|
|
26
|
+
export function withRail(html: string, o: { tools: HubTool[]; active: string }): string {
|
|
27
|
+
const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...o.tools.filter((t) => t.path !== '/')]
|
|
28
|
+
.map((t) => {
|
|
29
|
+
const on = o.active === t.path || (t.path !== '/' && o.active.startsWith(t.path))
|
|
30
|
+
const dot = t.path === '/' ? '<i id=skdot style="display:none"></i>' : ''
|
|
31
|
+
return `<a class="${on ? 'on' : ''}" href="${esc(t.path)}" title="${esc(t.label)} — ${esc(t.desc)}">${RAIL_ICONS[t.path] ?? '·'}${dot}</a>`
|
|
32
|
+
})
|
|
33
|
+
.join('')
|
|
34
|
+
// the red dot fetches client-side: a passive page nav must never run the
|
|
35
|
+
// attention collector server-side (cost + heal side-effects)
|
|
36
|
+
const dotScript = `<script>fetch('/api/attention.json').then(r=>r.json()).then(j=>{const n=(j.sections||[]).filter(s=>s.tone==='red').reduce((a,s)=>a+s.lines.length,0);if(n>0)document.getElementById('skdot').style.display=''}).catch(()=>{})</script>`
|
|
37
|
+
return html.replace(/<body([^>]*)>/, `<body$1><style>${RAIL_CSS}</style><nav class=skrail>${items}</nav>${dotScript}`)
|
|
84
38
|
}
|
|
39
|
+
|
|
40
|
+
// ── attention data shapes (collector in run-serve, rendering in hub-app) ──
|
|
41
|
+
export type AttentionLine = { text: string; href?: string }
|
|
42
|
+
export type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
|
|
43
|
+
|
|
44
|
+
export const attentionRedCount = (sections: AttentionSection[]): number =>
|
|
45
|
+
sections.filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -5,14 +5,20 @@
|
|
|
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 } from 'fs'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
|
-
import { loadKit } from '../assets/kit-config.ts'
|
|
11
|
+
import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
|
|
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'
|
|
12
15
|
import { page as studioPage } from '../assets/server/studio-page.ts'
|
|
13
16
|
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
14
|
-
import {
|
|
17
|
+
import { openAsks } from '../asks/asks-queue.ts'
|
|
18
|
+
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
19
|
+
import { activeContracts, contractsConfigFrom, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
15
20
|
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
21
|
+
import type { AttentionSection } from './hub-pages.ts'
|
|
16
22
|
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
17
23
|
|
|
18
24
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
@@ -32,12 +38,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
32
38
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
33
39
|
: null
|
|
34
40
|
let studio: StudioRoutes | null = null
|
|
41
|
+
let kit: ResolvedKit | null = null
|
|
35
42
|
if (existsSync(join(root, '.rv-assets/kit.ts'))) {
|
|
36
43
|
// a broken kit must not take the graphs and status down with it —
|
|
37
44
|
// WARN + mount everything else
|
|
38
45
|
try {
|
|
39
|
-
|
|
46
|
+
kit = await loadKit(root)
|
|
47
|
+
studio = await studioRoutes({ kit, root, page: studioPage })
|
|
40
48
|
} catch (e) {
|
|
49
|
+
kit = null
|
|
41
50
|
io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -50,6 +59,90 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
50
59
|
agentGraph,
|
|
51
60
|
studio,
|
|
52
61
|
checkCmd: [process.execPath, cli, 'check'],
|
|
62
|
+
attention: attentionCollector(root, c, kit),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// the hub must KNOW when it is outdated (field bug 2026-07-22: a morning
|
|
67
|
+
// process served stale code all day, picks looked broken): version read at
|
|
68
|
+
// boot vs on disk per attention load; mismatch = red line telling him to
|
|
69
|
+
// restart the task
|
|
70
|
+
const libVersion = (): string => {
|
|
71
|
+
try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
72
|
+
}
|
|
73
|
+
const BOOT_VERSION = libVersion()
|
|
74
|
+
|
|
75
|
+
// ── the / dashboard's data: everything red, one collector per source, each
|
|
76
|
+
// failure-isolated (a broken source reports itself, never blanks the page)
|
|
77
|
+
export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
|
|
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
|
|
53
146
|
}
|
|
54
147
|
}
|
|
55
148
|
|