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.
@@ -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 = { name?: string; private?: boolean; publishConfig?: unknown; scripts?: Record<string, string> }
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
- if (pkg.name?.startsWith('rvlib-') === true) return t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
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 — his words are never erased
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 — his words land in .rv-assets/notes.md
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 (small mono, auto-grows to
64
- its content, scrolls only past ~14 lines), then ONE slim toolbar row */
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
- .bar .prompt{min-height:36px;max-height:252px;overflow-y:auto;resize:none;font:11px/1.5 ui-monospace,Menlo,monospace;padding:7px 10px}
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
- if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = 'asset'
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 · custom panels ─
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 (asset json · roll meta ·
3
- // repo-defined custom panels). Feature parity with the deleted vanilla
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) — 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)} />
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 · custom panels ───────────
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
- {[{ id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }, ...customs.map((c) => ({ id: `c:${c.id}`, label: c.id }))].map((t) => (
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
- <button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>JSON ▾</button>
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">
@@ -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 (his catch
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
- io.print(`ok: ${c.name} hub → ${base}`)
113
- // the '/' tool IS the banner line above — printing it again was noise
114
- for (const t of hub.tools) if (t.path !== '/') io.print(`ok: ${t.label} → ${base}${t.path}`)
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(`ok: public → http://${c.serve.publicHost}:${hub.server.port}/`)
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(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
130
- for (const t of owner.tools ?? []) if (t.path !== '/') io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
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`)
@@ -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 — his words
103
- // land in the item's own truth file
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: a dir that is already a real project
70
- // (package.json) gets wired on the spot, so a pre-existing repo goes
71
- // 0 → shipkit'd with the one command. init itself stays cwd-only, like
72
- // git init. A brand-new empty dir gets git + its remote all the same, and
73
- // is told what is next, because the shipkit scaffold needs a package.json.
74
- // --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
75
- // this list), not just no CLAUDE.md. And the cd contract survives a
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'))) {