rvlib-shipkit 1.0.1 → 1.1.1
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 +57 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +30 -4
- package/src/check.ts +6 -0
- package/src/checks/SK005-rvlib-versions.check.ts +18 -3
- package/src/checks/SK021-github-login.check.ts +123 -0
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Check.ts +1 -1
- package/src/cli/cmd_Ci.ts +1 -1
- package/src/cli/cmd_Fleet.ts +5 -57
- package/src/cli.ts +2 -3
- package/src/collections/checks.ts +12 -2
- package/src/collections/collection-types.ts +34 -18
- package/src/collections/comfy.ts +1 -0
- package/src/collections/posts.ts +1 -0
- package/src/collections/providers.ts +71 -43
- package/src/collections/reflections.ts +1 -0
- package/src/collections/state-rules.ts +29 -13
- package/src/collections/tests.ts +1 -0
- package/src/collections/wikis.ts +1 -0
- package/src/core/budget.ts +60 -0
- package/src/core/check-ctx.ts +2 -1
- package/src/core/check-runner.ts +43 -6
- package/src/core/fd-table.ts +37 -0
- package/src/core/git-root.ts +26 -2
- package/src/core/lib-version.ts +16 -0
- package/src/core/migrate-layout.ts +2 -1
- package/src/core/motherhome.ts +2 -4
- package/src/core/package-manager.ts +4 -2
- package/src/core/raw-access.ts +11 -3
- package/src/core/reexec.ts +26 -0
- package/src/core/run-check.ts +36 -16
- package/src/core/run-ci.ts +29 -24
- package/src/core/self-update.ts +59 -24
- package/src/core/update-policy.ts +25 -0
- package/src/fleet/scan-fleet.ts +4 -15
- package/src/fleet/update-fleet.ts +85 -0
- package/src/ledger/gate-checks.ts +19 -5
- package/src/ledger/git-key.ts +10 -5
- package/src/ledger/git-scan.ts +29 -17
- package/src/ledger/ledger.ts +14 -6
- package/src/ledger/repo-view.ts +8 -6
- package/src/ledger/run-log.ts +10 -4
- package/src/ledger/run-timeline.ts +21 -9
- package/src/problems/problem-board.ts +13 -2
- package/src/problems/problem-line.ts +9 -0
- package/src/repo-config.ts +22 -1
- package/src/serve/hub-app/check-view.tsx +12 -11
- package/src/serve/hub-app/cols-svc.ts +16 -9
- package/src/serve/hub-app/dur.ts +10 -1
- package/src/serve/hub-app/header-bar.tsx +11 -0
- package/src/serve/hub-app/help.tsx +1 -0
- package/src/serve/hub-app/hub-st.ts +3 -4
- package/src/serve/hub-app/hub-types.ts +5 -0
- package/src/serve/hub-app/hub.css +3 -0
- package/src/serve/hub-app/timeline-view.tsx +4 -4
- package/src/serve/hub-app/tree-build.ts +1 -2
- package/src/serve/hub-problems.ts +35 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/run-serve.ts +51 -12
- package/src/serve/serve-hub.ts +10 -3
- package/src/serve/stale-restart.ts +77 -0
- package/src/suite/suite-cli.ts +14 -10
- package/src/ts/source-graph.ts +3 -2
package/src/core/motherhome.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { RepoConfig } from '../repo-config.ts'
|
|
|
17
17
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
18
|
import { isLinkedWorktree } from './git-root.ts'
|
|
19
19
|
import { machineCommit } from './machine-commit.ts'
|
|
20
|
+
import { LIB_VERSION } from './lib-version.ts'
|
|
20
21
|
|
|
21
22
|
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
22
23
|
const END = '<!-- <<< shipkit local-projects -->'
|
|
@@ -53,9 +54,6 @@ const parseRow = (line: string): Row | null => {
|
|
|
53
54
|
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function libVersion(): string {
|
|
57
|
-
try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
58
|
-
}
|
|
59
57
|
|
|
60
58
|
// how this repo consumes the lib — `?` on an unreadable package.json (a
|
|
61
59
|
// read failure must never be rendered as the fact 'path')
|
|
@@ -91,7 +89,7 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
|
|
|
91
89
|
const mode = consumptionMode(root)
|
|
92
90
|
// source-consumed code is not the published release its version
|
|
93
91
|
// string names — disclose it
|
|
94
|
-
const version = mode === 'npm' ?
|
|
92
|
+
const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
|
|
95
93
|
const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
|
|
96
94
|
|
|
97
95
|
const r = withFileLock(page, (): MotherhomeOutcome => {
|
|
@@ -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
|
|
73
|
+
export function detectPackageManager(root: string, tracked?: string[]): PackageManagerPick {
|
|
74
74
|
const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
|
|
75
|
-
|
|
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] : []),
|
package/src/core/raw-access.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -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
|
|
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
|
|
232
|
-
print(`FAIL:
|
|
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) {
|
package/src/core/run-ci.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
234
|
-
sink(`FAIL:
|
|
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
|
package/src/core/self-update.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
+
/** overrides the per-machine cache file (tests) */
|
|
71
|
+
export const SELF_UPDATE_CACHE_ENV = 'SHIPKIT_SELF_UPDATE_CACHE'
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
if (
|
|
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
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
export
|
|
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
|
-
|
|
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}\``
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|