rvlib-shipkit 0.22.2 → 0.24.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 +35 -2
- package/package.json +7 -4
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Mv.ts +7 -10
- package/src/cli/cmd_Start.ts +2 -2
- package/src/collections/actions.ts +3 -3
- package/src/collections/collection-types.ts +1 -1
- package/src/collections/providers.ts +2 -2
- package/src/core/check-suite.ts +3 -3
- package/src/core/run-fixers.ts +3 -4
- package/src/git-bootstrap.ts +29 -4
- package/src/hooks/leak-guard.ts +6 -0
- package/src/hooks/run-hooks.ts +3 -3
- package/src/reflect/prompt-corpus.ts +1 -1
- package/src/release/run-bump.ts +29 -0
- package/src/release/run-release.ts +22 -0
- package/src/release/run-releases.ts +5 -1
- package/src/release/units.ts +48 -2
- package/src/repo-config.ts +5 -4
- package/src/rules/SK005-rvlib-versions.ts +13 -1
- package/src/rules/SK006-npm-publish-safety.ts +37 -3
- package/src/rules/SK011-sibling-unit-ranges.ts +91 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/serve/hub-app/cols-svc.ts +1 -1
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/hub-app/hub.css +9 -3
- package/src/serve/hub-app/media-svc.ts +19 -4
- package/src/serve/hub-app/media-view.tsx +22 -9
- package/src/serve/run-serve.ts +10 -7
- package/src/serve/serve-hub.ts +2 -2
- package/src/start-repo.ts +50 -8
- package/src/ts/config.ts +185 -0
- package/src/ts/cycles.ts +205 -0
- package/src/ts/imports.ts +255 -0
- package/src/ts/rename.ts +238 -0
- package/src/ts/source-graph-cli.ts +19 -0
- package/src/ts/source-mask.ts +26 -0
|
@@ -14,9 +14,23 @@
|
|
|
14
14
|
// and sanctioned sub-lib publishing must not turn this rule red.
|
|
15
15
|
import { existsSync, readFileSync } from 'fs'
|
|
16
16
|
import { join } from 'path'
|
|
17
|
+
import { resolveUnits } from '../release/units.ts'
|
|
17
18
|
import type { Rule } from './rule.ts'
|
|
18
19
|
|
|
19
|
-
type Pkg = {
|
|
20
|
+
type Pkg = {
|
|
21
|
+
name?: string
|
|
22
|
+
private?: boolean
|
|
23
|
+
publishConfig?: unknown
|
|
24
|
+
scripts?: Record<string, string>
|
|
25
|
+
dependencies?: Record<string, string>
|
|
26
|
+
peerDependencies?: Record<string, string>
|
|
27
|
+
optionalDependencies?: Record<string, string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** protocols that resolve to a path on THIS machine. In a block a consumer
|
|
31
|
+
* installs, they are unresolvable: the published package is broken on
|
|
32
|
+
* arrival, and it fails at install time for a stranger, never here. */
|
|
33
|
+
const LOCAL_PROTOCOL = /^(link|file|portal|workspace):/
|
|
20
34
|
|
|
21
35
|
export const npmPublishSafety: Rule = {
|
|
22
36
|
id: 'SK006',
|
|
@@ -28,7 +42,27 @@ export const npmPublishSafety: Rule = {
|
|
|
28
42
|
if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
|
|
29
43
|
let pkg: Pkg
|
|
30
44
|
try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { return t.note('package.json unreadable — SK001 reports it') }
|
|
31
|
-
|
|
45
|
+
// every block a CONSUMER resolves, for every unit that can publish:
|
|
46
|
+
// the ROOT is usually private, so scanning it alone guards nothing
|
|
47
|
+
const localHits: string[] = []
|
|
48
|
+
for (const u of [{ dir: env.root, id: 'root' }, ...resolveUnits(env.root, env.config).units.filter((x) => x.rel !== '')]) {
|
|
49
|
+
const up = join(u.dir, 'package.json')
|
|
50
|
+
if (!existsSync(up)) continue
|
|
51
|
+
let upkg: Pkg
|
|
52
|
+
try { upkg = JSON.parse(readFileSync(up, 'utf8')) as Pkg } catch { continue }
|
|
53
|
+
if (upkg.private === true) continue
|
|
54
|
+
for (const block of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const)
|
|
55
|
+
for (const [dep, range] of Object.entries(upkg[block] ?? {}))
|
|
56
|
+
if (LOCAL_PROTOCOL.test(range)) localHits.push(`${u.id}: ${block}.${dep} = ${range}`)
|
|
57
|
+
}
|
|
58
|
+
if (localHits.length > 0)
|
|
59
|
+
t.fail(
|
|
60
|
+
`${localHits.join(', ')} — a local protocol cannot resolve on a consumer's machine, so publishing that ships a package which fails at install. Declare the version it WILL be, and keep the local wiring in overrides (npm ignores those from a dependency) or in devDependencies`,
|
|
61
|
+
)
|
|
62
|
+
if (pkg.name?.startsWith('rvlib-') === true) {
|
|
63
|
+
if (localHits.length === 0) t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
32
66
|
if (pkg.private !== true) return t.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
|
|
33
67
|
const machinery: string[] = []
|
|
34
68
|
if (pkg.publishConfig != null) machinery.push('publishConfig')
|
|
@@ -38,6 +72,6 @@ export const npmPublishSafety: Rule = {
|
|
|
38
72
|
machinery.push(`scripts.publish (runs npm/bun publish)`)
|
|
39
73
|
if (machinery.length > 0)
|
|
40
74
|
t.fail(`private package carries publish machinery: ${machinery.join(', ')} — dead weight now, a loaded gun the day private flips; delete it or make the repo a published lib on purpose`)
|
|
41
|
-
else t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root`)
|
|
75
|
+
else if (localHits.length === 0) t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root, no local dependency protocol in any unit`)
|
|
42
76
|
},
|
|
43
77
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SK011 — a dependency range on a SIBLING release unit must be satisfied by
|
|
2
|
+
// the version that sibling actually carries (build/shipkit.md, release units).
|
|
3
|
+
//
|
|
4
|
+
// The failure it exists for: one unit is edited and its dependents keep a
|
|
5
|
+
// range that names a version nobody published, or an older one that lacks the
|
|
6
|
+
// code they now call. Both are invisible until a consumer installs, which is
|
|
7
|
+
// the worst place to find out. `shipkit bump` moves these ranges forward on
|
|
8
|
+
// every bump, so a red here means someone hand-edited a range, or a bump was
|
|
9
|
+
// interrupted.
|
|
10
|
+
//
|
|
11
|
+
// Local protocols (link:, file:) are SK006's story, not this rule's.
|
|
12
|
+
import { existsSync, readFileSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import { resolveUnits } from '../release/units.ts'
|
|
15
|
+
import type { Rule } from './rule.ts'
|
|
16
|
+
|
|
17
|
+
type Pkg = {
|
|
18
|
+
dependencies?: Record<string, string>
|
|
19
|
+
peerDependencies?: Record<string, string>
|
|
20
|
+
optionalDependencies?: Record<string, string>
|
|
21
|
+
devDependencies?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BLOCKS = ['dependencies', 'peerDependencies', 'optionalDependencies', 'devDependencies'] as const
|
|
25
|
+
|
|
26
|
+
/** does this range name the sibling's CURRENT version as its floor? Not
|
|
27
|
+
* "does it resolve": `^0.1.1` happily resolves 0.1.2, and then a consumer
|
|
28
|
+
* whose tree already holds 0.1.1 keeps it and misses the release. The floor
|
|
29
|
+
* moves on every bump, so a stale one means a bump nobody propagated.
|
|
30
|
+
* Deliberately small: `^x.y.z`, `~x.y.z` or an exact pin, and a range shape
|
|
31
|
+
* it cannot read is SKIPPED rather than guessed at. */
|
|
32
|
+
export function floorMatches(version: string, range: string): boolean | null {
|
|
33
|
+
const r = range.trim().match(/^([\^~]?)(\d+\.\d+\.\d+)$/)
|
|
34
|
+
if (r == null || !/^\d+\.\d+\.\d+/.test(version)) return null
|
|
35
|
+
return r[2] === version
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** does `version` satisfy a caret/tilde/exact range? Kept for the resolution
|
|
39
|
+
* question, which is a different one from the floor question above. */
|
|
40
|
+
export function satisfiesSimple(version: string, range: string): boolean | null {
|
|
41
|
+
const v = version.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
42
|
+
const r = range.trim().match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)$/)
|
|
43
|
+
if (v == null || r == null) return null
|
|
44
|
+
const [, op, RA, RI, RP] = r as unknown as [string, string, string, string, string]
|
|
45
|
+
const [va, vi, vp] = [Number(v[1]), Number(v[2]), Number(v[3])]
|
|
46
|
+
const [ra, ri, rp] = [Number(RA), Number(RI), Number(RP)]
|
|
47
|
+
const atLeast = va > ra || (va === ra && (vi > ri || (vi === ri && vp >= rp)))
|
|
48
|
+
if (op === '^') {
|
|
49
|
+
// caret on 0.x pins the MINOR: ^0.1.4 accepts 0.1.9, never 0.2.0
|
|
50
|
+
if (ra === 0) return va === 0 && vi === ri && atLeast
|
|
51
|
+
return va === ra && atLeast
|
|
52
|
+
}
|
|
53
|
+
if (op === '~') return va === ra && vi === ri && atLeast
|
|
54
|
+
return va === ra && vi === ri && vp === rp
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const siblingUnitRanges: Rule = {
|
|
58
|
+
id: 'SK011',
|
|
59
|
+
blocking: true,
|
|
60
|
+
title: 'sibling unit ranges',
|
|
61
|
+
owner: 'build/shipkit.md',
|
|
62
|
+
lint: (t, env) => {
|
|
63
|
+
const { units } = resolveUnits(env.root, env.config)
|
|
64
|
+
const version = new Map(units.map((u) => [u.name, u.version]))
|
|
65
|
+
if (units.length < 2) return t.ok('one release unit — no sibling ranges to keep honest')
|
|
66
|
+
const bad: string[] = []
|
|
67
|
+
let checked = 0
|
|
68
|
+
for (const u of units) {
|
|
69
|
+
const p = join(u.dir, 'package.json')
|
|
70
|
+
if (!existsSync(p)) continue
|
|
71
|
+
let pkg: Pkg
|
|
72
|
+
try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { continue }
|
|
73
|
+
for (const block of BLOCKS)
|
|
74
|
+
for (const [dep, range] of Object.entries(pkg[block] ?? {})) {
|
|
75
|
+
const actual = version.get(dep)
|
|
76
|
+
if (actual == null) continue
|
|
77
|
+
if (/^(link|file|portal|workspace):/.test(range)) continue
|
|
78
|
+
const ok = floorMatches(actual, range)
|
|
79
|
+
if (ok == null) continue
|
|
80
|
+
checked++
|
|
81
|
+
if (!ok)
|
|
82
|
+
bad.push(
|
|
83
|
+
`${u.id}: ${block}.${dep} floors at ${range}, ${dep} is ${actual}${satisfiesSimple(actual, range) === true ? ' (it resolves, but a tree already holding the older one keeps it)' : ''}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (bad.length > 0)
|
|
88
|
+
t.fail(`${bad.join(' · ')} — a sibling range FLOORS at the version it names, so a stale floor lets a consumer keep an older copy and miss the release. \`shipkit bump\` moves every floor forward in the version commit, so a red here is a bump nobody propagated`)
|
|
89
|
+
else t.ok(`${checked} sibling range(s) floor at exactly the version each unit carries`)
|
|
90
|
+
},
|
|
91
|
+
}
|
|
@@ -13,6 +13,7 @@ import { githubVisibility } from './SK007-github-visibility.ts'
|
|
|
13
13
|
import { reviewerAgent } from './SK008-reviewer-agent.ts'
|
|
14
14
|
import { commitAiTells } from './SK009-commit-ai-tells.ts'
|
|
15
15
|
import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
|
|
16
|
+
import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
|
|
16
17
|
|
|
17
18
|
export const RULES: readonly Rule[] = [
|
|
18
19
|
packageJsonShape,
|
|
@@ -25,6 +26,7 @@ export const RULES: readonly Rule[] = [
|
|
|
25
26
|
reviewerAgent,
|
|
26
27
|
commitAiTells,
|
|
27
28
|
undeclaredReleaseUnit,
|
|
29
|
+
siblingUnitRanges,
|
|
28
30
|
]
|
|
29
31
|
|
|
30
32
|
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
|
@@ -129,7 +129,7 @@ export class ColsSvc {
|
|
|
129
129
|
async remark(body: Record<string, unknown>): Promise<void> {
|
|
130
130
|
const text = this.remarkDraft.trim()
|
|
131
131
|
if (text === '') return
|
|
132
|
-
// draft survives a failed send
|
|
132
|
+
// draft survives a failed send, so nothing typed is ever erased
|
|
133
133
|
const ok = await this.act('remark', { ...body, text })
|
|
134
134
|
if (ok) runInAction(() => { this.remarkDraft = '' })
|
|
135
135
|
}
|
|
@@ -173,7 +173,7 @@ const Palette = observer(() => {
|
|
|
173
173
|
)
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
-
// remark box on the ASSET view too
|
|
176
|
+
// remark box on the ASSET view too: remarks land in .rv-assets/notes.md
|
|
177
177
|
// and come back as `remarks` (exchange both ways, media included)
|
|
178
178
|
const AssetRemarks = observer(() => {
|
|
179
179
|
const m = app.media
|
|
@@ -60,12 +60,18 @@ aside{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto
|
|
|
60
60
|
|
|
61
61
|
/* ── main column ── */
|
|
62
62
|
main{display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
|
63
|
-
/* topbar: the prompt is its OWN full-width row (
|
|
64
|
-
|
|
63
|
+
/* topbar: the prompt is its OWN full-width row (folded by default to one
|
|
64
|
+
ellipsized line; expanded = small mono, auto-grows to its content,
|
|
65
|
+
scrolls only past ~14 lines), then ONE slim toolbar row */
|
|
65
66
|
.bar{display:flex;flex-direction:column;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
66
67
|
textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
|
|
67
68
|
border-radius:9px;padding:9px 11px;font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
|
|
68
|
-
.
|
|
69
|
+
.promptrow{display:flex;gap:6px;align-items:flex-start}
|
|
70
|
+
.promptrow .fold{flex:none;background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:9px;
|
|
71
|
+
padding:6px 9px;font-size:11px;line-height:1.5}
|
|
72
|
+
.bar .prompt{flex:1;min-height:36px;max-height:252px;overflow-y:auto;resize:none;font:11px/1.5 ui-monospace,Menlo,monospace;padding:7px 10px}
|
|
73
|
+
.bar .prompt.folded{min-height:0;max-height:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;cursor:pointer;
|
|
74
|
+
color:var(--dim);background:var(--bg);border:1px solid var(--line);border-radius:9px}
|
|
69
75
|
.toolbar{display:flex;gap:8px;align-items:center;min-height:30px}
|
|
70
76
|
.toolbar button{padding:5px 11px;font-size:12px}
|
|
71
77
|
.toolbar .sp{flex:1}
|
|
@@ -58,6 +58,11 @@ export class MediaSvc {
|
|
|
58
58
|
drawerOpen = false
|
|
59
59
|
jsonText = ''
|
|
60
60
|
jsonMsg = ''
|
|
61
|
+
/** prompt folded to one ellipsized line (session state, click toggles) */
|
|
62
|
+
promptOpen = false
|
|
63
|
+
|
|
64
|
+
/** latched by picking a NON-default tab, until then a sole custom panel is the default */
|
|
65
|
+
private tabPicked = false
|
|
61
66
|
|
|
62
67
|
private hbTimer: ReturnType<typeof setInterval> | null = null
|
|
63
68
|
private rectTimer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -66,8 +71,8 @@ export class MediaSvc {
|
|
|
66
71
|
private savedPrompt = ''
|
|
67
72
|
|
|
68
73
|
constructor(private st: HubSt) {
|
|
69
|
-
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt'>(
|
|
70
|
-
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false })
|
|
74
|
+
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'tabPicked'>(
|
|
75
|
+
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, tabPicked: false })
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
start(): void {
|
|
@@ -161,7 +166,11 @@ export class MediaSvc {
|
|
|
161
166
|
this.savedPrompt = r.asset.prompt
|
|
162
167
|
this.promptMsg = ''
|
|
163
168
|
if (this.deterministic) this.n = '1'
|
|
164
|
-
|
|
169
|
+
// custom panels are the working surfaces, a kind with exactly one
|
|
170
|
+
// gets it as the default tab (setTab latches an explicit divergence)
|
|
171
|
+
const sole = r.asset.custom.length === 1 ? `c:${r.asset.custom[0]!.id}` : null
|
|
172
|
+
if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = sole ?? 'asset'
|
|
173
|
+
else if (!this.tabPicked && sole != null) this.tab = sole
|
|
165
174
|
this.pickActive(r)
|
|
166
175
|
})
|
|
167
176
|
if (this.hasGeom) await this.loadRect()
|
|
@@ -279,6 +288,9 @@ export class MediaSvc {
|
|
|
279
288
|
this.promptMsg = 'copied'
|
|
280
289
|
setTimeout(() => runInAction(() => { this.promptMsg = '' }), 900)
|
|
281
290
|
}
|
|
291
|
+
togglePrompt(): void {
|
|
292
|
+
this.promptOpen = !this.promptOpen
|
|
293
|
+
}
|
|
282
294
|
|
|
283
295
|
toggleFold(cat: string): void {
|
|
284
296
|
if (this.st.query.trim() !== '') return // folds yield to a live search
|
|
@@ -370,9 +382,12 @@ export class MediaSvc {
|
|
|
370
382
|
await this.fillCat()
|
|
371
383
|
}
|
|
372
384
|
|
|
373
|
-
// ── drawer: asset json (read-only) · roll meta sidecar
|
|
385
|
+
// ── drawer: custom panels · asset json (read-only) · roll meta sidecar ─
|
|
374
386
|
setTab(tab: string): void {
|
|
375
387
|
this.tab = tab
|
|
388
|
+
// clicking the sole panel IS the default, only a different pick latches
|
|
389
|
+
const c = this.detail?.asset.custom ?? []
|
|
390
|
+
if (tab !== (c.length === 1 ? `c:${c[0]!.id}` : null)) this.tabPicked = true
|
|
376
391
|
this.drawerOpen = true
|
|
377
392
|
void this.loadTab()
|
|
378
393
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Media views — topbar, info chips, stage (image/audio + geometry), roll
|
|
2
|
-
// strip, category grid + size-true lineup, drawer (
|
|
3
|
-
//
|
|
2
|
+
// strip, category grid + size-true lineup, drawer (repo-defined custom
|
|
3
|
+
// panels · asset json · roll meta). Feature parity with the deleted vanilla
|
|
4
4
|
// studio SPA; state lives in MediaSvc (components read and call).
|
|
5
5
|
import { runInAction } from 'mobx'
|
|
6
6
|
import { observer } from 'mobx-react-lite'
|
|
@@ -27,12 +27,23 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
|
|
|
27
27
|
if (t == null) return
|
|
28
28
|
t.style.height = 'auto'
|
|
29
29
|
t.style.height = `${t.scrollHeight + 2}px`
|
|
30
|
-
}, [m.prompt])
|
|
30
|
+
}, [m.prompt, m.promptOpen])
|
|
31
31
|
return (
|
|
32
32
|
<div className="bar">
|
|
33
|
-
{/* the prompt AUTOSAVES (debounced)
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
{/* the prompt AUTOSAVES (debounced), no save button, no fear.
|
|
34
|
+
folded (default) = one ellipsized line, click to expand */}
|
|
35
|
+
<div className="promptrow">
|
|
36
|
+
<button className="fold" title={m.promptOpen ? 'fold the prompt' : 'expand the prompt'}
|
|
37
|
+
onClick={() => m.togglePrompt()}>{m.promptOpen ? '▾' : '▸'}</button>
|
|
38
|
+
{m.promptOpen
|
|
39
|
+
? <textarea className="prompt" ref={taRef} value={m.prompt} placeholder="select an asset ←"
|
|
40
|
+
onChange={(e) => m.setPrompt(e.target.value)} />
|
|
41
|
+
: <div className="prompt folded" onClick={() => m.togglePrompt()}>
|
|
42
|
+
{/* placeholder only when NOTHING is selected, a selected
|
|
43
|
+
asset with an empty prompt shows an empty line */}
|
|
44
|
+
{m.curId == null ? 'select an asset ←' : m.prompt === '' ? '\u00a0' : m.prompt}
|
|
45
|
+
</div>}
|
|
46
|
+
</div>
|
|
36
47
|
<div className="toolbar">
|
|
37
48
|
{!manual ? <button disabled={m.curId == null} onClick={() => void m.generate()}>Generate</button> : null}
|
|
38
49
|
{!manual ? (
|
|
@@ -232,7 +243,7 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
232
243
|
)
|
|
233
244
|
})
|
|
234
245
|
|
|
235
|
-
// ── drawer: asset json (read-only) · roll meta
|
|
246
|
+
// ── drawer: custom panels · asset json (read-only) · roll meta ───────────
|
|
236
247
|
const PanelHost = observer(({ app }: { app: HubSt }) => {
|
|
237
248
|
const m = app.media
|
|
238
249
|
const ref = useRef<HTMLDivElement>(null)
|
|
@@ -273,11 +284,13 @@ export const Drawer = observer(({ app }: { app: HubSt }) => {
|
|
|
273
284
|
return (
|
|
274
285
|
<div className="drawer">
|
|
275
286
|
<div className="tabs">
|
|
276
|
-
{
|
|
287
|
+
{/* customs lead: they are the working surfaces, json tabs are diagnostics */}
|
|
288
|
+
{[...customs.map((c) => ({ id: `c:${c.id}`, label: c.id })), { id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }].map((t) => (
|
|
277
289
|
<button key={t.id} className={m.tab === t.id ? 'on' : ''} onClick={() => m.setTab(t.id)}>{t.label}</button>
|
|
278
290
|
))}
|
|
279
291
|
<span className="sp" />
|
|
280
|
-
|
|
292
|
+
{/* names the drawer, not the tab set (a sole custom panel is the default content) */}
|
|
293
|
+
<button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>drawer ▾</button>
|
|
281
294
|
</div>
|
|
282
295
|
{m.drawerOpen ? (
|
|
283
296
|
<div id="drawerbody" className="on">
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function defaultHubPort(name: string): number {
|
|
|
28
28
|
|
|
29
29
|
export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
|
|
30
30
|
// the default wiki graph exists only when the repo HAS a wiki dir: a
|
|
31
|
-
// game repo's hub advertising a wiki route was startup noise
|
|
31
|
+
// game repo's hub advertising a wiki route was startup noise
|
|
32
32
|
// 2026-08-05). A config-declared wikiGraph always mounts.
|
|
33
33
|
const wikiGraph = c.serve?.wikiGraph != null
|
|
34
34
|
? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
|
|
@@ -109,11 +109,13 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
109
109
|
try {
|
|
110
110
|
const hub = serveHub(deps)
|
|
111
111
|
const base = `http://localhost:${hub.server.port}`
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
112
|
+
// a banner, not a checklist: one clickable line, then what is inside,
|
|
113
|
+
// dimmed by indentation. Wire prefixes stay reserved for problems.
|
|
114
|
+
io.print(`${c.name} hub → ${base}`)
|
|
115
|
+
const routes = hub.tools.filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
|
|
116
|
+
if (routes !== '') io.print(` also: ${routes}`)
|
|
115
117
|
// the off-machine door (phone, other machines) — publicHost is config
|
|
116
|
-
if (c.serve?.publicHost != null) io.print(`
|
|
118
|
+
if (c.serve?.publicHost != null) io.print(` phone: http://${c.serve.publicHost}:${hub.server.port}/`)
|
|
117
119
|
await new Promise(() => {}) // the hub owns the process — never resolve
|
|
118
120
|
return 0
|
|
119
121
|
} catch (e) {
|
|
@@ -126,8 +128,9 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
126
128
|
if (owner?.hub === 'shipkit' && owner.name === c.name) {
|
|
127
129
|
// a re-run terminal stays as clickable as a first run — the
|
|
128
130
|
// owner's /hub.json tool list is the same source of truth
|
|
129
|
-
io.print(
|
|
130
|
-
|
|
131
|
+
io.print(`${c.name} hub → http://localhost:${deps.port} (already running in another window)`)
|
|
132
|
+
const routes = (owner.tools ?? []).filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
|
|
133
|
+
if (routes !== '') io.print(` also: ${routes}`)
|
|
131
134
|
return 0
|
|
132
135
|
}
|
|
133
136
|
io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -99,8 +99,8 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
99
99
|
}
|
|
100
100
|
else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
|
|
101
101
|
else if (path === '/api/act/remark') {
|
|
102
|
-
// ONE remark verb, routed by collection
|
|
103
|
-
//
|
|
102
|
+
// ONE remark verb, routed by collection: a remark
|
|
103
|
+
// lands in the item's own truth file
|
|
104
104
|
const text = (b.text ?? '').trim()
|
|
105
105
|
if (text === '') return new Response('empty remark', { status: 400 })
|
|
106
106
|
if (b.kind === 'ask') appendAskRemark(d.root, d.asksDir, b.file ?? '', text)
|
package/src/start-repo.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
8
|
import { homedir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
10
|
+
import { ensureGitRepo, firstGhLine, IGNORE_BASELINE, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
|
|
11
11
|
import { initRepo } from './init-repo.ts'
|
|
12
12
|
|
|
13
13
|
export type StartOpts = {
|
|
@@ -24,6 +24,9 @@ export type StartOpts = {
|
|
|
24
24
|
github?: boolean
|
|
25
25
|
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
26
26
|
gh?: Run | null
|
|
27
|
+
/** how `bun` runs for the dep install — injected by tests, which never
|
|
28
|
+
* touch the network. An explicit null means no bun on this machine */
|
|
29
|
+
bun?: Run | null
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
|
|
@@ -66,13 +69,17 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
|
-
// start is the from-anywhere verb
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
72
|
+
// start is the from-anywhere verb, and a STARTED repo comes out READY:
|
|
73
|
+
// a brand-new dir gets a minimal package.json, a first src/index.ts, the
|
|
74
|
+
// rvlib-shipkit dev dep, repo.config.ts via init, git, first commit,
|
|
75
|
+
// private github repo, push. A dir that already holds a project
|
|
76
|
+
// (package.json) gets the same minus the scaffold. A PRE-EXISTING dir
|
|
77
|
+
// with NO project is the everyday cd into some old folder: git only,
|
|
78
|
+
// never a scaffold (--github forces only the github half, so it must
|
|
79
|
+
// never write a package.json into a folder that holds someone's files).
|
|
80
|
+
// init itself stays cwd-only, like git init.
|
|
81
|
+
// --skip means shipkit NEVER TOUCHES a listed repo (a day-job checkout
|
|
82
|
+
// belongs on that list), not just no CLAUDE.md. And the cd contract survives a
|
|
76
83
|
// failed adoption: the dir always prints, wiring trouble is a WARN.
|
|
77
84
|
if (!(opts.skip ?? []).includes(name)) {
|
|
78
85
|
// git BEFORE the shipkit scaffold, and whether or not the dir is a
|
|
@@ -83,6 +90,41 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
83
90
|
let wired = false
|
|
84
91
|
try { wired = await ensureGitRepo(dir, { print }) }
|
|
85
92
|
catch (e) { console.error(`WARN could not wire git in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
93
|
+
// the scaffold fires ONLY for a dir this run created (never --github:
|
|
94
|
+
// forcing the github half on an old folder must not write a project
|
|
95
|
+
// into it), and only where git actually wired (a subdirectory of
|
|
96
|
+
// another work tree gets nothing): the minimal package.json lands so
|
|
97
|
+
// init below has its floor and the first commit carries a working
|
|
98
|
+
// repo, not an empty husk
|
|
99
|
+
if (wired && justCreated && !existsSync(join(dir, 'package.json')) && !existsSync(join(dir, 'repo.config.ts'))) {
|
|
100
|
+
const pkg = { name, version: '0.0.0', private: true, type: 'module', scripts: {} }
|
|
101
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 4) + '\n')
|
|
102
|
+
// the first source file, so the ci gate is green from commit one:
|
|
103
|
+
// an imports gate over zero files fails loud on purpose
|
|
104
|
+
mkdirSync(join(dir, 'src'), { recursive: true })
|
|
105
|
+
writeFileSync(join(dir, 'src', 'index.ts'), 'export {}\n')
|
|
106
|
+
console.error('ok: package.json + src/index.ts scaffolded (private, type module)')
|
|
107
|
+
// the install below creates node_modules/ BEFORE the first
|
|
108
|
+
// commit, so the baseline ignore must exist first (SK004's block,
|
|
109
|
+
// written by init, does not carry node_modules)
|
|
110
|
+
if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
|
|
111
|
+
// the scripts init wires call node_modules/rvlib-shipkit — a
|
|
112
|
+
// scaffold whose scripts exit 127 is not a started repo. Skips
|
|
113
|
+
// mirror the github half's guards: an injected runner wins, the
|
|
114
|
+
// env switch turns it off machine-wide, a temp dir is a rehearsal
|
|
115
|
+
// that must not pull the network. init owns the fix line for
|
|
116
|
+
// every not-installed outcome
|
|
117
|
+
let bun: Run | null
|
|
118
|
+
if (opts.bun !== undefined) bun = opts.bun
|
|
119
|
+
else if ((process.env.SHIPKIT_NO_INSTALL ?? '') !== '') { console.error('skip: rvlib-shipkit install — SHIPKIT_NO_INSTALL is set'); bun = null }
|
|
120
|
+
else if (isScratch(dir)) { console.error(`skip: rvlib-shipkit install — ${dir} is a temp dir`); bun = null }
|
|
121
|
+
else bun = Bun.which('bun') == null ? null : runner('bun', dir, 180_000)
|
|
122
|
+
if (bun != null) {
|
|
123
|
+
const r = await bun(['add', '-d', 'rvlib-shipkit'])
|
|
124
|
+
if (r.code === 0) console.error('ok: rvlib-shipkit installed (dev dep)')
|
|
125
|
+
else console.error(`WARN rvlib-shipkit install failed: ${firstGhLine(r.out)}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
86
128
|
let unscaffolded = false
|
|
87
129
|
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
88
130
|
if (existsSync(join(dir, 'package.json'))) {
|