rvlib-shipkit 1.0.1 → 1.1.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/check.ts +6 -0
  5. package/src/checks/SK005-rvlib-versions.check.ts +18 -3
  6. package/src/checks/SK021-github-login.check.ts +123 -0
  7. package/src/cli/cmd_Asks.ts +1 -1
  8. package/src/cli/cmd_Check.ts +1 -1
  9. package/src/cli/cmd_Ci.ts +1 -1
  10. package/src/cli/cmd_Fleet.ts +5 -57
  11. package/src/cli.ts +2 -3
  12. package/src/collections/checks.ts +12 -2
  13. package/src/collections/collection-types.ts +34 -18
  14. package/src/collections/comfy.ts +1 -0
  15. package/src/collections/posts.ts +1 -0
  16. package/src/collections/providers.ts +71 -43
  17. package/src/collections/reflections.ts +1 -0
  18. package/src/collections/state-rules.ts +29 -13
  19. package/src/collections/tests.ts +1 -0
  20. package/src/collections/wikis.ts +1 -0
  21. package/src/core/budget.ts +60 -0
  22. package/src/core/check-ctx.ts +2 -1
  23. package/src/core/check-runner.ts +43 -6
  24. package/src/core/fd-table.ts +37 -0
  25. package/src/core/git-root.ts +26 -2
  26. package/src/core/lib-version.ts +16 -0
  27. package/src/core/migrate-layout.ts +2 -1
  28. package/src/core/motherhome.ts +2 -4
  29. package/src/core/package-manager.ts +4 -2
  30. package/src/core/raw-access.ts +11 -3
  31. package/src/core/reexec.ts +26 -0
  32. package/src/core/run-check.ts +36 -16
  33. package/src/core/run-ci.ts +29 -24
  34. package/src/core/self-update.ts +59 -24
  35. package/src/core/update-policy.ts +25 -0
  36. package/src/fleet/scan-fleet.ts +4 -15
  37. package/src/fleet/update-fleet.ts +85 -0
  38. package/src/ledger/gate-checks.ts +19 -5
  39. package/src/ledger/git-key.ts +10 -5
  40. package/src/ledger/git-scan.ts +29 -17
  41. package/src/ledger/ledger.ts +14 -6
  42. package/src/ledger/repo-view.ts +8 -6
  43. package/src/ledger/run-log.ts +10 -4
  44. package/src/ledger/run-timeline.ts +21 -9
  45. package/src/problems/problem-board.ts +13 -2
  46. package/src/problems/problem-line.ts +9 -0
  47. package/src/repo-config.ts +22 -1
  48. package/src/serve/hub-app/check-view.tsx +12 -11
  49. package/src/serve/hub-app/cols-svc.ts +16 -9
  50. package/src/serve/hub-app/dur.ts +10 -1
  51. package/src/serve/hub-app/header-bar.tsx +11 -0
  52. package/src/serve/hub-app/help.tsx +1 -0
  53. package/src/serve/hub-app/hub-st.ts +3 -4
  54. package/src/serve/hub-app/hub-types.ts +5 -0
  55. package/src/serve/hub-app/hub.css +3 -0
  56. package/src/serve/hub-app/timeline-view.tsx +4 -4
  57. package/src/serve/hub-app/tree-build.ts +1 -2
  58. package/src/serve/hub-problems.ts +35 -0
  59. package/src/serve/run-serve.ts +51 -12
  60. package/src/serve/serve-hub.ts +10 -3
  61. package/src/serve/stale-restart.ts +77 -0
  62. package/src/suite/suite-cli.ts +14 -10
  63. package/src/ts/source-graph.ts +3 -2
@@ -70,9 +70,11 @@ export function trackedLocks(root: string): string[] {
70
70
  }
71
71
 
72
72
  /** the manager this repo installs with, read off its lock files */
73
- export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManagerPick {
73
+ export function detectPackageManager(root: string, tracked?: string[]): PackageManagerPick {
74
74
  const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
75
- const owned = present.filter((f) => tracked.includes(f))
75
+ // git is asked which locks it tracks only when there is a lock to ask about
76
+ const known = present.length === 0 ? [] : (tracked ?? trackedLocks(root))
77
+ const owned = present.filter((f) => known.includes(f))
76
78
  const locks = owned.length > 0 ? owned : present
77
79
  const managers = [
78
80
  ...(locks.some((f) => BUN_LOCKS.includes(f)) ? [bun] : []),
@@ -72,17 +72,25 @@ function scanSource(src: string, o: ClosureKind): { line: number; what: string }
72
72
  * scanned, and neither is `skip`, the toolkit's own tree when it sits inside
73
73
  * the repo: the toolchain id covers it exactly as it covers an install.
74
74
  * Throws when the closure cannot be walked, which the caller counts as a hit */
75
- export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig> } & ClosureKind = {}): RawAccess | null {
75
+ export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig>; texts?: Map<string, string> } & ClosureKind = {}): RawAccess | null {
76
76
  // one pass over many checks under one root hands in `configs`, so the
77
77
  // root's config (a walk of its source tree) is read once for all of them
78
78
  let loaded = o.configs?.get(root)
79
79
  if (loaded == null) { loaded = loadConfig(root); o.configs?.set(root, loaded) }
80
80
  const cfg = { ...loaded, root }
81
81
  const skipped = (f: string): boolean => f.split('/').includes('node_modules') || (o.skip != null && join(root, f).startsWith(`${o.skip}/`))
82
- const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], o).filter((f) => !skipped(f))
82
+ // one pass over many checks hands in `texts` too: their closures share
83
+ // most of their files, and each is read once for the whole pass
84
+ const read = (rel: string): string => {
85
+ const p = join(root, rel)
86
+ let text = o.texts?.get(p)
87
+ if (text == null) { text = readFileSync(p, 'utf8'); o.texts?.set(p, text) }
88
+ return text
89
+ }
90
+ const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], { ...o, read }).filter((f) => !skipped(f))
83
91
  for (const f of files) {
84
92
  let src: string
85
- try { src = readFileSync(join(root, f), 'utf8') } catch { continue }
93
+ try { src = read(f) } catch { continue }
86
94
  const hit = rawAccessInSource(src, o)
87
95
  if (hit != null) return { file: f, ...hit }
88
96
  }
@@ -0,0 +1,26 @@
1
+ // run this exact invocation again, on whatever code is on disk now. execve
2
+ // replaces the image in place: same pid, same terminal, same argv (so the
3
+ // same flags and the same port), and a `--watch` flag stays in execArgv. A
4
+ // runtime without execve runs the invocation as a child and exits with its
5
+ // code, which keeps the terminal but leaves this process waiting under it.
6
+ import { existsSync } from 'fs'
7
+
8
+ const argvOf = (): string[] => [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
9
+
10
+ /** why this invocation cannot run again, null when it can. Asked BEFORE a
11
+ * caller tears anything down, so a refusal leaves the process serving */
12
+ export function reexecBlocker(): string | null {
13
+ const entry = process.argv[1]
14
+ if (entry != null && entry !== '' && !entry.startsWith('-') && /\.[cm]?[jt]sx?$/.test(entry) && !existsSync(entry)) return `its entry file ${entry} is gone`
15
+ return null
16
+ }
17
+
18
+ /** never returns. `env` defaults to this process's own */
19
+ export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
20
+ const argv = argvOf()
21
+ const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
22
+ const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
23
+ if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
24
+ const child = Bun.spawnSync(argv, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env: clean })
25
+ process.exit(child.exitCode ?? 1)
26
+ }
@@ -14,10 +14,11 @@ import { Ledger } from '../ledger/ledger.ts'
14
14
  import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
15
15
  import { storeRoot } from '../ledger/store.ts'
16
16
  import { gateBinary } from '../ledger/toolchain-identity.ts'
17
- import { runChecks, type Audience, type CheckEvent, type CheckResult } from './check-runner.ts'
17
+ import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
18
18
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
19
19
  import { reportToMotherhome } from './motherhome.ts'
20
20
  import { unknownWhenIds } from './when.ts'
21
+ import { unknownBudgetIds } from './budget.ts'
21
22
  import { formatLine, type Line } from './wire.ts'
22
23
  import { writeStatus } from './write-status.ts'
23
24
  import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
@@ -106,12 +107,37 @@ export type CheckRun = {
106
107
  /** `when` entries naming no check of this repo — a toggle that does
107
108
  * nothing is worse than none, so it is fatal too */
108
109
  unknownWhen: string[]
110
+ /** `budgets` entries naming no check of this repo, fatal for the same reason */
111
+ unknownBudgets: string[]
109
112
  /** what every asked-for check did, in declaration order */
110
113
  results: CheckResult[]
111
114
  /** the run was interrupted or bailed out */
112
115
  interrupted: boolean
113
116
  }
114
117
 
118
+ /** what the runner selects from, built from the pass options in ONE place: the
119
+ * ledger is scoped on the same selection the runner then makes */
120
+ function selectionOf(checks: RunnableCheck[], o: CheckOpts, tier: TestTier) {
121
+ return {
122
+ checks,
123
+ audience: o.audience ?? 'health',
124
+ fullTier: tier === 'full',
125
+ skipGroups: o.skipGroups,
126
+ onlyIds: o.only,
127
+ withIds: o.withIds,
128
+ onlyBlocking: o.onlyBlocking,
129
+ buildOnly: o.buildOnly,
130
+ includeProactive: o.includeProactive,
131
+ exclude: o.exclude,
132
+ } satisfies Parameters<typeof selectChecks>[0]
133
+ }
134
+
135
+ /** the ids a pass with these options executes: the scope its ledger scans for */
136
+ export function passScope(composed: GateCheck[], o: CheckOpts): Set<string> {
137
+ const tier: TestTier = o.tier ?? envTier()
138
+ return new Set(selectChecks(selectionOf(composed.map((g) => g.runnable), o, tier)).selected.map((u) => u.id))
139
+ }
140
+
115
141
  /** THE run body: compose this repo's checks, run the set the audience asks
116
142
  * for, and classify the outcome by what each check declared. `shipkit check`
117
143
  * and `shipkit ci` both come through here, and nothing else decides
@@ -135,7 +161,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
135
161
  let ledger: Ledger | null = o.ledger ?? null
136
162
  if (ledger === null && o.ledger === undefined) {
137
163
  try {
138
- ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh })
164
+ ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: o.cliEntry ?? gateBinary(), fresh: o.fresh, scope: passScope(composed, o) })
139
165
  } catch { ledger = null }
140
166
  }
141
167
  // the caller's log, or this run's own: `shipkit check` measures checks too,
@@ -157,24 +183,15 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
157
183
  const collected: Line[] = []
158
184
  const sink = (l: string): void => print(l)
159
185
  const summary = await runChecks({
160
- checks,
186
+ ...selectionOf(checks, o, tier),
161
187
  root,
162
188
  config: c,
163
- audience,
164
- fullTier: tier === 'full',
165
189
  logDir: c.logDir ?? join(root, CI_LOG_DIR),
166
190
  cache: { storeDir: storeRoot(root), ns: c.name, now: Date.now },
167
191
  ledger,
168
192
  log,
169
193
  childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
170
194
  bail: o.bail,
171
- skipGroups: o.skipGroups,
172
- onlyIds: o.only,
173
- withIds: o.withIds,
174
- onlyBlocking: o.onlyBlocking,
175
- buildOnly: o.buildOnly,
176
- includeProactive: o.includeProactive,
177
- exclude: o.exclude,
178
195
  print: sink,
179
196
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
180
197
  })
@@ -205,13 +222,16 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
205
222
  if (o.runLog === undefined && log != null) for (const l of log.flush()) say(l)
206
223
  // a `when` id naming nothing is a switch wired to no check: the id is read
207
224
  // against the WHOLE inventory this run composed, never its narrowed list
208
- const unknownWhen = unknownWhenIds(composed.map((g) => g.row), c)
225
+ const rows = composed.map((g) => g.row)
226
+ const unknownWhen = unknownWhenIds(rows, c)
227
+ const unknownBudgets = unknownBudgetIds(rows.map((r) => r.decl.id), c.budgets)
209
228
  return {
210
229
  lines: [...collected, ...lines],
211
230
  blocked: !summary.ok,
212
231
  advisoryFailures: summary.advisoryFailures,
213
232
  undeclared: summary.undeclared,
214
233
  unknownWhen,
234
+ unknownBudgets,
215
235
  results: summary.results,
216
236
  interrupted: summary.interrupted,
217
237
  }
@@ -222,14 +242,14 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
222
242
  const print = o.print ?? ((l: string) => console.log(l))
223
243
  const c = o.config ?? (await loadRepoConfig(root))
224
244
  const run = await runCheckPass(root, { ...o, config: c })
225
- const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
245
+ const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
226
246
  if (o.json === true) { print(JSON.stringify(run)); return code }
227
247
  for (const u of run.undeclared) {
228
248
  print(`FAIL: ${u} does not declare whether it blocks`)
229
249
  print(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
230
250
  }
231
- for (const id of run.unknownWhen) {
232
- print(`FAIL: when.${id} names no check of this repo`)
251
+ for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
252
+ print(`FAIL: ${key} names no check of this repo`)
233
253
  print(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
234
254
  }
235
255
  if (o.status !== false) {
@@ -8,7 +8,7 @@
8
8
  // --no-fix is passed.
9
9
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
10
  import { reportToMotherhome } from './motherhome.ts'
11
- import { runCheckPass, type CheckOpts } from './run-check.ts'
11
+ import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
12
12
  import { poolLines } from './slots.ts'
13
13
  import { runFixPass } from './run-fixers.ts'
14
14
  import { formatLine } from './wire.ts'
@@ -169,11 +169,34 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
169
169
  // ONE walk for the whole gate: the ledger is keyed on it and the run
170
170
  // measures it, and two walks can disagree about what exists
171
171
  const composed = await composeChecks(root, c, { tier })
172
+ // `gate = false` opts a check out of the everyday gate, and failOnAdvisory
173
+ // (release.requireHealthy) re-arms the world probes, because a repo asking
174
+ // for exactly those must not have them silently dropped by the opt-out
175
+ const passOpts: CheckOpts = {
176
+ status: false,
177
+ config: c,
178
+ composed,
179
+ audience: 'gate',
180
+ includeProactive: o.failOnAdvisory === true,
181
+ frozen: o.frozen,
182
+ onlyBlocking: o.onlyBlockingChecks,
183
+ only: o.onlyIds,
184
+ withIds: o.withIds,
185
+ skipGroups: o.skipGroups,
186
+ exclude: o.skipTaskIds,
187
+ buildOnly: o.buildOnly,
188
+ bail: o.bail,
189
+ fresh: o.fresh,
190
+ tier,
191
+ }
172
192
  try {
173
193
  ledger = Ledger.open(root, composed.map((g) => g.row.decl), {
174
194
  cliEntry: o.cliEntry ?? gateBinary(),
175
195
  read: o.ledgerRead,
176
196
  fresh: o.fresh,
197
+ // only what this run executes is scanned: an `--only` run pays for
198
+ // its own checks, never for the whole inventory
199
+ scope: passScope(composed, passOpts),
177
200
  })
178
201
  const reg = ledger.layer.registration
179
202
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
@@ -191,27 +214,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
191
214
  ledger = null
192
215
  sink(`WARN cache unavailable (${String(e).slice(0, 160)}) — every check runs this time, so the gate is correct and slower`)
193
216
  }
194
- // THE RUN. One engine, one DAG, one pass over everything this gate asks
195
- // for: `gate = false` opts a check out of the everyday gate, and
196
- // failOnAdvisory (release.requireHealthy) re-arms the world probes,
197
- // because a repo asking for exactly those must not have them silently
198
- // dropped by the opt-out.
217
+ // THE RUN. One engine, one DAG, one pass over everything this gate asks for
199
218
  const run = await runCheckPass(root, {
200
- status: false,
201
- config: c,
202
- composed,
203
- audience: 'gate',
204
- includeProactive: o.failOnAdvisory === true,
205
- frozen: o.frozen,
206
- onlyBlocking: o.onlyBlockingChecks,
207
- only: o.onlyIds,
208
- withIds: o.withIds,
209
- skipGroups: o.skipGroups,
210
- exclude: o.skipTaskIds,
211
- buildOnly: o.buildOnly,
212
- bail: o.bail,
213
- fresh: o.fresh,
214
- tier,
219
+ ...passOpts,
215
220
  ledger,
216
221
  runLog,
217
222
  onCheck: o.onCheck,
@@ -230,8 +235,8 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
230
235
  }
231
236
  // a switch wired to no check does nothing, silently, which is the one thing
232
237
  // a toggle must never do
233
- for (const id of run.unknownWhen) {
234
- sink(`FAIL: when.${id} names no check of this repo`)
238
+ for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
239
+ sink(`FAIL: ${key} names no check of this repo`)
235
240
  sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
236
241
  }
237
242
  let advisoryFatal = false
@@ -249,7 +254,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
249
254
  if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
250
255
  // one write for the whole gate, after everything has finished
251
256
  for (const l of runLog.flush()) sink(formatLine(l, false))
252
- const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
257
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
253
258
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
254
259
  // and never fires under frozen: a bump/release gate must not re-dirty the
255
260
  // tree it just proved clean
@@ -10,12 +10,16 @@
10
10
  // re-exec guard set, dirty package.json/lockfile (a tool must not mix its
11
11
  // writes into human-staged work), registry unreachable (offline is not a
12
12
  // defect). every skip is silent except the actionable ones, which WARN.
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
13
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
14
14
  import { homedir } from 'os'
15
- import { dirname, join } from 'path'
15
+ import { basename, dirname, join } from 'path'
16
16
  import { fileURLToPath } from 'url'
17
17
  import { detectPackageManager, isRefusal } from './package-manager.ts'
18
18
  import { gitBin } from './git-bin.ts'
19
+ import { reexecSelf } from './reexec.ts'
20
+ import { LIB_ROOT } from './lib-root.ts'
21
+ import { FRESH_ENV } from '../ledger/store.ts'
22
+ import { allowMajor, breakingAdvice, isBreaking } from './update-policy.ts'
19
23
 
20
24
  const PKG = 'rvlib-shipkit'
21
25
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -34,6 +38,10 @@ export function planSelfUpdate(o: {
34
38
  ci: boolean
35
39
  frozen: boolean
36
40
  manifestDirty: boolean
41
+ /** `fleet --update --major` asked for this jump on purpose */
42
+ allowMajor: boolean
43
+ /** the repo dir name, for the advice a breaking jump prints */
44
+ repo: string
37
45
  }): SelfUpdatePlan {
38
46
  if (o.reexecGuard) return { action: 'skip', reason: 'already re-executed once' }
39
47
  if (o.ci) return { action: 'skip', reason: 'CI env' }
@@ -43,6 +51,7 @@ export function planSelfUpdate(o: {
43
51
  if (o.latest == null) return { action: 'skip', reason: 'registry unreachable' }
44
52
  if (o.installed === o.latest) return { action: 'skip', reason: 'current' }
45
53
  if (newerThan(o.installed, o.latest)) return { action: 'skip', reason: 'ahead of the registry' }
54
+ if (isBreaking(o.installed, o.latest) && !o.allowMajor) return { action: 'warn', reason: breakingAdvice(PKG, o.latest, o.repo) }
46
55
  if (o.manifestDirty) return { action: 'warn', reason: `rvlib-shipkit ${o.installed} is behind ${o.latest}, package.json or lockfile dirty, not touching — commit, then rerun` }
47
56
  return { action: 'update', installed: o.installed, latest: o.latest }
48
57
  }
@@ -58,23 +67,40 @@ const newerThan = (a: string, b: string): boolean => {
58
67
  return false
59
68
  }
60
69
 
61
- const cachePath = (): string => join(homedir(), '.cache', 'shipkit', 'self-update.json')
70
+ /** overrides the per-machine cache file (tests) */
71
+ export const SELF_UPDATE_CACHE_ENV = 'SHIPKIT_SELF_UPDATE_CACHE'
62
72
 
63
- /** npm latest with a per-machine 24h cache, null when unreachable */
64
- async function latestCached(): Promise<string | null> {
73
+ const cachePath = (): string => {
74
+ const env = process.env[SELF_UPDATE_CACHE_ENV]
75
+ return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'self-update.json')
76
+ }
77
+
78
+ /** store a registry answer another path measured, so the next startup
79
+ * decides on it instead of on an older cached one */
80
+ export function rememberLatest(version: string): void {
65
81
  const p = cachePath()
66
- try {
82
+ mkdirSync(dirname(p), { recursive: true })
83
+ writeFileSync(p, JSON.stringify({ at: Date.now(), version }))
84
+ }
85
+
86
+ const npmLatest = async (): Promise<string | null> => {
87
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(5000) })
88
+ if (!res.ok) return null
89
+ return ((await res.json()) as { version?: string }).version ?? null
90
+ }
91
+
92
+ /** npm latest with a per-machine 24h cache, null when unreachable. A fresh
93
+ * run skips the cache: it decides the one update that restarts the run, and
94
+ * a stale answer here leaves that update to SK005, mid run */
95
+ export async function latestCached(o: { fresh: boolean; fetchLatest?: () => Promise<string | null> }): Promise<string | null> {
96
+ const p = cachePath()
97
+ if (!o.fresh) try {
67
98
  const c = JSON.parse(readFileSync(p, 'utf8')) as { at: number; version: string }
68
99
  if (Date.now() - c.at < CACHE_TTL_MS) return c.version
69
100
  } catch { /* no cache yet */ }
70
101
  try {
71
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(5000) })
72
- if (!res.ok) return null
73
- const v = ((await res.json()) as { version?: string }).version ?? null
74
- if (v != null) {
75
- mkdirSync(dirname(p), { recursive: true })
76
- writeFileSync(p, JSON.stringify({ at: Date.now(), version: v }))
77
- }
102
+ const v = await (o.fetchLatest ?? npmLatest)()
103
+ if (v != null) rememberLatest(v)
78
104
  return v
79
105
  } catch {
80
106
  return null
@@ -87,10 +113,22 @@ const manifestDirty = (root: string): boolean => {
87
113
  return p.stdout.toString().trim() !== ''
88
114
  }
89
115
 
90
- /** run before dispatching an interactive ci/check. On update: re-exec the
91
- * SAME argv with a loop guard and exit with the child's code (never
92
- * returns). Otherwise returns after at most one cached registry probe. */
93
- export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
116
+ /** true when `root`'s installed rvlib-shipkit is the code of THIS process.
117
+ * Updating that tree in process would leave the rest of the run importing
118
+ * modules of two versions, so only the startup path below may update it */
119
+ export function isRunningCopy(root: string): boolean {
120
+ try {
121
+ return realpathSync(join(root, 'node_modules', PKG)) === realpathSync(LIB_ROOT)
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ /** run before dispatching an interactive ci/check, so no check has loaded
128
+ * yet. On update: re-exec the SAME argv with a loop guard and exit with the
129
+ * child's code (never returns). Otherwise returns after at most one registry
130
+ * probe, cached unless the run is fresh (`--fresh` or SHIPKIT_FRESH=1). */
131
+ export async function maybeSelfUpdateAndRestart(root: string, o: { fresh?: boolean } = {}): Promise<void> {
94
132
  const here = fileURLToPath(import.meta.url)
95
133
  const base = {
96
134
  installed: installedVersion(root),
@@ -98,10 +136,12 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
98
136
  reexecGuard: process.env.SHIPKIT_SELF_UPDATED === '1',
99
137
  ci: process.env.CI != null && process.env.CI !== '',
100
138
  frozen: process.env.SHIPKIT_FROZEN === '1',
139
+ allowMajor: allowMajor(),
140
+ repo: basename(root),
101
141
  }
102
142
  // cheap gates first: no network unless an update is even possible
103
143
  if (base.reexecGuard || base.ci || base.frozen || !base.runningFromNodeModules || base.installed == null) return
104
- const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
144
+ const plan = planSelfUpdate({ ...base, latest: await latestCached({ fresh: o.fresh === true || process.env[FRESH_ENV] === '1' }), manifestDirty: manifestDirty(root) })
105
145
  if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
106
146
  if (plan.action !== 'update') return
107
147
  const pm = detectPackageManager(root)
@@ -111,12 +151,7 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
111
151
  const up = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
112
152
  if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
113
153
  console.error(`ok: restarting on ${plan.latest} — commit package.json + lockfile with your work`)
114
- const child = Bun.spawnSync([process.execPath, ...process.argv.slice(1)], {
115
- cwd: process.cwd(),
116
- stdout: 'inherit', stderr: 'inherit', stdin: 'inherit',
117
- env: { ...process.env, SHIPKIT_SELF_UPDATED: '1' },
118
- })
119
- process.exit(child.exitCode ?? 1)
154
+ reexecSelf({ ...process.env, SHIPKIT_SELF_UPDATED: '1' })
120
155
  }
121
156
 
122
157
  const installedVersion = (root: string): string | null => {
@@ -0,0 +1,25 @@
1
+ // the ONE update policy every automatic update path asks: rule SK005, the
2
+ // startup self-update and `shipkit fleet --update`. An automatic update never
3
+ // crosses a breaking boundary. It reports the jump and names the one command
4
+ // that takes it on purpose.
5
+
6
+ /** set by `shipkit fleet --update --major` on the repo run it spawns */
7
+ export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
8
+
9
+ export const allowMajor = (): boolean => process.env[ALLOW_MAJOR_ENV] === '1'
10
+
11
+ /** a jump across the boundary npm's caret draws: a changed major, or on a
12
+ * `0.x` package a changed minor (`^0.2.0` means `<0.3.0`). A porting job
13
+ * rather than a bump, so no automatic path takes it. */
14
+ export function isBreaking(installed: string, latest: string): boolean {
15
+ const parts = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
16
+ const [aMajor = 0, aMinor = 0] = parts(installed)
17
+ const [bMajor = 0, bMinor = 0] = parts(latest)
18
+ if (aMajor !== bMajor) return true
19
+ return aMajor === 0 && aMinor !== bMinor
20
+ }
21
+
22
+ /** the WARN text for a jump no automatic path takes. `repo` is the directory
23
+ * name, which is what `fleet --only` matches */
24
+ export const breakingAdvice = (name: string, latest: string, repo: string): string =>
25
+ `${name} ${latest} available (breaking): run \`shipkit fleet --update --major --only ${repo}\``
@@ -8,11 +8,12 @@
8
8
  //
9
9
  // This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
10
10
  // update, with the lease, the dirty-tree guard and the frozen guard that took
11
- // three bugs to get right. `--update` runs each repo's own `shipkit check`,
12
- // so the fix travels through that one code path.
11
+ // three bugs to get right. `--update` (update-fleet.ts) runs each repo's own
12
+ // `shipkit check`, so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
16
+ import { isBreaking } from '../core/update-policy.ts'
16
17
 
17
18
  export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
18
19
 
@@ -26,18 +27,6 @@ export type FleetDep = {
26
27
  latest: string | null
27
28
  }
28
29
 
29
- /** a jump that changes the MAJOR, which is a porting job rather than a bump.
30
- *
31
- * Deliberately NOT the semver rule. On a `0.x` package npm treats every
32
- * minor as breaking, and by that rule every repo here is breaking, including
33
- * the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
34
- * every day. A flag that fires on everything is a flag nobody reads. What
35
- * survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
36
- export function isBreaking(installed: string, latest: string): boolean {
37
- const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
38
- return major(installed) !== major(latest)
39
- }
40
-
41
30
  export type FleetRepo = {
42
31
  name: string
43
32
  dir: string
@@ -62,7 +51,7 @@ const readPkg = (dir: string): Pkg | null => {
62
51
  }
63
52
  }
64
53
 
65
- const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
54
+ export const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
66
55
 
67
56
  /** semver-ish compare, enough to answer "is this one behind that one". A
68
57
  * prerelease tag sorts before its release, which is the safe direction: it
@@ -0,0 +1,85 @@
1
+ // `shipkit fleet --update`: hand each behind repo its own update. The update
2
+ // is NOT performed here. Each repo runs its own binary, which is the only copy
3
+ // whose SK005 matches the rules that repo is pinned to, and the only one
4
+ // holding that repo's mutate lease.
5
+ import { existsSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
9
+ import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
10
+ import { installedVersion, isBehind, updateRefusal, type FleetRepo } from './scan-fleet.ts'
11
+
12
+
13
+ export const short = (name: string): string => name.replace(/^rvlib-/, '')
14
+
15
+ // the hand-run fallback speaks the manager THAT repo installs with: telling
16
+ // an npm consumer to run bun leaves its package-lock.json behind
17
+ const manualUpdate = (r: FleetRepo): string => {
18
+ const pm = detectPackageManager(r.dir)
19
+ return isRefusal(pm) ? `<its package manager> update ${r.behind.map((d) => d.name).join(' ')} (${pm.refused})` : pm.update(r.behind.map((d) => d.name)).join(' ')
20
+ }
21
+
22
+ export type FleetUpdateOpts = {
23
+ /** cross a breaking boundary too (`--major`) */
24
+ major: boolean
25
+ print: (line: string) => void
26
+ /** per repo run (default 10 minutes: a fresh health pass probes the world) */
27
+ timeoutMs?: number
28
+ }
29
+
30
+ /** returns how many repos did not update. A repo whose gate refused (dirty
31
+ * tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
32
+ * command: SK005 defers on purpose */
33
+ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Promise<number> {
34
+ const w = o.print
35
+ let failed = 0
36
+ for (const r of behind) {
37
+ // the same policy SK005 applies inside the repo: a breaking jump is reported,
38
+ // never taken, and a safe jump beside it still moves
39
+ const held = o.major ? [] : r.breaking
40
+ for (const d of held) w(`WARN ${r.name}: ${breakingAdvice(d.name, d.latest!, r.name)}`)
41
+ const wanted = r.behind.filter((d) => !held.includes(d))
42
+ if (wanted.length === 0) {
43
+ failed++
44
+ continue
45
+ }
46
+ const refused = updateRefusal(r)
47
+ if (refused != null) {
48
+ w(`WARN ${r.name}: not updating: ${refused}`)
49
+ failed++
50
+ continue
51
+ }
52
+ const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
53
+ if (!existsSync(cli)) {
54
+ w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && ${manualUpdate(r)}`)
55
+ failed++
56
+ continue
57
+ }
58
+ // `--fresh` because SK005 caches a clean pass for 24h: a plain `check`
59
+ // replays the answer it gave before this release existed, and the run
60
+ // meant to update never asks the registry. `check --fix SK005` is no
61
+ // way around it: SK005 has no autofix, so the binary refuses the id.
62
+ // The re-exec guard is dropped so the repo's own startup self-update
63
+ // can still fire, and --major travels as the one env the policy reads.
64
+ const env: Record<string, string> = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
65
+ if (o.major) env[ALLOW_MAJOR_ENV] = '1'
66
+ const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
67
+ // judge by what node_modules carries now, never by the exit code or a
68
+ // printed line: the code covers every rule in that repo, and the line
69
+ // format belongs to whatever version that repo runs
70
+ const stuck = wanted.filter((d) => {
71
+ const now = installedVersion(r.dir, d.name)
72
+ return now == null || isBehind(now, d.latest!)
73
+ })
74
+ if (stuck.length === 0) {
75
+ w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)} ${d.installed} → ${installedVersion(r.dir, d.name)}`).join(', ')}`)
76
+ continue
77
+ }
78
+ failed++
79
+ const said = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('WARN') || l.startsWith('FAIL')))
80
+ w(`WARN ${r.name} did not move ${stuck.map((d) => short(d.name)).join(', ')} — ${said ?? `check exited ${res.code}${res.timedOut ? ', timed out' : ''} with no rvlib line`}`)
81
+ w(` fix: cd ${r.dir} && ${manualUpdate(r)}`)
82
+ }
83
+ w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why`)
84
+ return failed
85
+ }