omakit 0.6.8 → 0.6.9

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.
@@ -6,16 +6,19 @@
6
6
  // for automation, and a pipe without it refuses. The plan is computed
7
7
  // first and reads only; the disclosure prints it whole; then, in order:
8
8
  //
9
+ // 0. The newest release, found by release.mjs before the plan is made:
10
+ // the newest at or above the floor whose ISO, checksum and signature
11
+ // are published, signed by the key omakit ships. A setup whose host
12
+ // cannot build is refused before that read.
9
13
  // 1. The ISO, into $XDG_CACHE_HOME/omakit/lab/downloads/<sha256>/: a
10
- // literal GET of the pinned URL to a .part file, resumed by byte
11
- // range, or a copy of a local file named with --from. Never
12
- // "latest": the URL, the byte count, the digest and the signer are
13
- // the pin's.
14
+ // literal GET of the release's versioned URL to a .part file, resumed
15
+ // by byte range, or a copy of a local file named with --from.
14
16
  // 2. Verification, of a downloaded file and of a file already there
15
- // alike: the byte count, the SHA-256 against the pin, the published
16
- // sidecar against the pin, the detached signature against the
17
- // packaged key at the pinned fingerprint. A mismatch fails closed:
18
- // the file is left as .part, nothing is recorded, nothing boots it.
17
+ // alike: the byte count and the SHA-256 against what the release
18
+ // publishes, the sidecar fetched now against the digest read at the
19
+ // start, the detached signature against the packaged key at the
20
+ // pinned fingerprint. A mismatch fails closed: the file is left as
21
+ // .part, nothing is recorded, nothing boots it.
19
22
  // 3. The base, built by the pinned omarchy-iso toolchain's console
20
23
  // driver from the verified ISO, in a staging directory under the lab
21
24
  // (the driver derives its base directory from its own location, so
@@ -23,7 +26,9 @@
23
26
  // checkout: docs/history/2026-09-18-lab-inventory.md P6, P19), then
24
27
  // booted once by the lab's own driver to read the installed
25
28
  // omarchy package, hashed, made read-only, given its manifest, and
26
- // promoted with one rename.
29
+ // promoted with one rename. The base it replaces, and the downloads
30
+ // of older releases, are removed only after that: until the new base
31
+ // verifies, a run uses the old one.
27
32
  //
28
33
  // Never fetched: the toolchain. It is a git checkout at a pinned commit
29
34
  // with the packaged patch applied, and the one command that makes it is
@@ -31,14 +36,15 @@
31
36
  // harness against the pin.
32
37
 
33
38
  import { spawn, spawnSync } from "node:child_process"
34
- import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs"
39
+ import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
35
40
  import { join, resolve } from "node:path"
36
41
  import { getStream, GitHubError } from "../marketplace/github.mjs"
37
- import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
42
+ import { LAB_DIR, bytesBoth, compareVersions, durationWords, guestIsRelease, labPin, releaseOf, withRelease } from "./pin.mjs"
38
43
  import { allocatedBytes, copyIntoLab, inLab, labDir, labLayout, moveIntoLab, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
39
44
  import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
40
- import { judgeRelease, packagedKey, sha256File } from "./verify.mjs"
41
- import { BASE_FILES, downloadDir, inspectBase, inspectDownload, inspectToolchain, toolchainCommand } from "./inspect.mjs"
45
+ import { judgeRelease, packagedKey, sha256File, signatureIssuers, signedByPinned } from "./verify.mjs"
46
+ import { sidecarDigest, smallBody } from "./release.mjs"
47
+ import { BASE_FILES, baseRelease, buildGuests, downloadDir, downloadEntry, inspectBase, inspectDownload, inspectToolchain, toolchainCommand } from "./inspect.mjs"
42
48
  import { LabError, acquireLock, freePort, releaseLock, withGuest } from "./run.mjs"
43
49
  import { WEIGH_LISTED, listedPlugin } from "./suites.mjs"
44
50
  import { marketplacePinDir } from "../marketplace/pin.mjs"
@@ -62,66 +68,186 @@ export function recordToolchain({ dir, env = process.env, pin = labPin() }) {
62
68
  return { dir: checkout, sha256 }
63
69
  }
64
70
 
71
+ /** The newest release, not looked up yet: what the first, network-free plan is made with. */
72
+ export const NOT_ASKED = Object.freeze({ checked: false, code: "not-asked", reason: "the newest release was not looked up" })
73
+
74
+ /**
75
+ * The release a local file names when the release list cannot be read:
76
+ * `omarchy-<version>.iso`, with its `.sha256` and `.sig` beside it, at or
77
+ * above the floor, signed (by the packet's own word) by the key omakit
78
+ * ships. Verification still hashes it and runs gpg before anything boots
79
+ * it; this only names what it claims to be. `{ release }` or `{ blocker }`.
80
+ */
81
+ export function localRelease(pin, file) {
82
+ const source = resolve(file)
83
+ const name = source.split("/").at(-1).match(/^omarchy-(\d+\.\d+\.\d+)\.iso$/)?.[1]
84
+ const command = "omakit lab setup --from <omarchy-X.Y.Z.iso> with its .sha256 and .sig beside it, or with the network, omakit lab setup"
85
+ if (!existsSync(source)) return { blocker: { what: "the file named by --from", cost: `${source} is not there`, command } }
86
+ if (!name) return { blocker: { what: "the file named by --from", cost: `${source} is not named omarchy-<version>.iso, so it names no release; without the network there is nothing else to name one`, command } }
87
+ if (compareVersions(name, pin.releases.floor) < 0) return { blocker: { what: "the file named by --from", cost: `Omarchy ${name} is older than ${pin.releases.floor}, the oldest release the lab takes`, command } }
88
+ let sha256 = null
89
+ try {
90
+ sha256 = sidecarDigest(readFileSync(`${source}.sha256`, "utf8"), source.split("/").at(-1))
91
+ } catch {
92
+ sha256 = null
93
+ }
94
+ if (!sha256) return { blocker: { what: "the checksum beside --from", cost: `${source}.sha256 is missing or does not name the file with a SHA-256`, command } }
95
+ let signers = null
96
+ try {
97
+ signers = signatureIssuers(readFileSync(`${source}.sig`))
98
+ } catch {
99
+ return { blocker: { what: "the signature beside --from", cost: `${source}.sig is not there; nothing boots a release whose signature was not checked`, command } }
100
+ }
101
+ if (signedByPinned(signers, pin.releases.signingFingerprint) === false) return { blocker: { what: "the signature beside --from", cost: `${source}.sig is by ${signers.map((signer) => signer.fingerprint || `key ${signer.keyId}`).join(" and ")}, not the key omakit ships (${pin.releases.signingFingerprint})`, command: "omakit upgrade" } }
102
+ return { release: releaseOf(pin, { name, bytes: statSync(source).size, sha256 }) }
103
+ }
104
+
105
+ /**
106
+ * Download directories a new base supersedes, removed once it verifies:
107
+ * every one but the release's own, except a newer release's. Never going
108
+ * back a release holds for the downloads too: with no base, a newer ISO
109
+ * verified by an earlier setup stays when this one builds an older
110
+ * release (its checksum was being republished when the search ran).
111
+ */
112
+ function supersededDownloads(layout, release) {
113
+ if (!existsSync(layout.downloads)) return []
114
+ return readdirSync(layout.downloads)
115
+ .filter((digest) => /^[0-9a-f]{64}$/.test(digest) && digest !== release.sha256)
116
+ .map((digest) => ({ digest, relative: `downloads/${digest}`, name: downloadEntry(join(layout.downloads, digest), digest).name, bytes: allocatedBytes(join(layout.downloads, digest)) }))
117
+ .filter((entry) => !entry.name || compareVersions(entry.name, release.name) <= 0)
118
+ }
119
+
65
120
  /**
66
121
  * The plan, reading only: what setup would do on this host, with every
67
122
  * size and the destination. `steps` is what the consent covers.
123
+ *
124
+ * `newest` is release.mjs's check. With NOT_ASKED the plan holds only what
125
+ * no release changes (the commands verification needs, and, when there is
126
+ * no usable base, what a build needs), so a setup that cannot start says
127
+ * so without a network read. With the newest release found, the plan
128
+ * prepares it; with the lookup failed, a usable base on disk is left as it
129
+ * is and the plan says the newest was not checked, and with none there,
130
+ * that is the blocker.
68
131
  */
69
- export function planSetup({ env = process.env, pin = labPin(), from = null, plugins = false, repoRoot } = {}) {
132
+ export function planSetup({ env = process.env, pin = labPin(), newest = NOT_ASKED, from = null, plugins = false, repoRoot } = {}) {
70
133
  const layout = labLayout(env)
71
- const download = inspectDownload(layout, pin)
72
- const base = inspectBase(layout, pin)
73
134
  const toolchain = inspectToolchain(layout, pin)
74
135
  const free = freeBytesAt(layout.cache)
75
136
  const steps = []
76
137
  const blockers = []
138
+ const buildBlockers = (withBuild) => {
139
+ for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
140
+ if (!withBuild) return
141
+ for (const line of probeCommands(BUILD_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
142
+ if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
143
+ }
77
144
  for (const line of probeCommands(VERIFY_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
145
+ // The base against its own release: whether a run can use what is there.
146
+ const own = baseRelease(layout, pin)
147
+ const current = inspectBase(layout, withRelease(pin, own))
148
+ const usable = current.state === "ready"
149
+ // The listed plugins are asked for whatever the base does: a base kept
150
+ // as it is (the newest not looked up, or one newer than the newest) is
151
+ // no reason to leave `prove weigh-evidence` without them.
152
+ let pluginsPlanned = false
153
+ const addPlugins = () => {
154
+ if (!plugins || pluginsPlanned) return
155
+ pluginsPlanned = true
156
+ const pinDir = marketplacePinDir(repoRoot)
157
+ const wanted = []
158
+ for (const id of WEIGH_LISTED) {
159
+ const listing = listedPlugin(pinDir, id)
160
+ if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
161
+ else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
162
+ }
163
+ if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
164
+ }
165
+ const done = (release, extra = {}) => ({ layout, pin: withRelease(pin, release), newest, download: null, base: current, toolchain, free, steps, blockers, needed: 0, afterBytes: 0, ...extra })
166
+ if (!newest.checked && newest.code === "not-asked") {
167
+ // Whatever the newest release turns out to be, a host with no usable
168
+ // base builds one: those blockers are known before the network.
169
+ if (!usable) buildBlockers(stagedBases(layout).length === 0)
170
+ return done(own, { asked: false })
171
+ }
172
+ let release = newest.checked ? newest.release : null
173
+ if (from && newest.checked) {
174
+ const named = resolve(from).split("/").at(-1).match(/^omarchy-(\d+\.\d+\.\d+)\.iso$/)?.[1]
175
+ if (named && named !== release.name) blockers.push({ what: "the file named by --from", cost: `${resolve(from)} is Omarchy ${named}; the newest release is ${release.name}, and setup prepares the newest`, command: `omakit lab setup --from <path to ${release.fileName}>, or without --from to download it` })
176
+ } else if (from) {
177
+ const local = localRelease(pin, from)
178
+ if (local.blocker) blockers.push(local.blocker)
179
+ release = local.release || null
180
+ }
181
+ if (!release) {
182
+ // The newest could not be looked up. A usable base stays as it is and
183
+ // the plan says what was not checked; with none, that is the blocker.
184
+ if (usable) {
185
+ addPlugins()
186
+ return done(own, { stale: { base: own.name, reason: newest.reason, code: newest.code } })
187
+ }
188
+ if (!from) blockers.push({ what: "the newest Omarchy release", cost: `it could not be looked up: ${newest.reason}`, command: "connect to the network and run omakit lab setup again, or omakit lab setup --from <omarchy-X.Y.Z.iso> with its .sha256 and .sig beside it" })
189
+ return done(null)
190
+ }
191
+ pin = withRelease(pin, release)
192
+ const download = inspectDownload(layout, pin)
193
+ const base = inspectBase(layout, pin)
194
+ // A good base newer than the release in hand is kept: the lab never goes
195
+ // back a release on its own (inspect.mjs, `ahead`), and nothing older is
196
+ // fetched or built over it.
197
+ if (base.state === "ahead") {
198
+ addPlugins()
199
+ return done(release, { download, base, ahead: { base: base.manifest.release.name, release: release.name, from: newest.checked ? "newest" : "from" } })
200
+ }
78
201
  if (!download.verified) {
79
202
  if (from) {
80
203
  const source = resolve(from)
81
- if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command: "omakit lab setup --from <path to omarchy-4.0.3.iso>" })
82
- else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir, pin.release.fileName), sidecars: { checksum: existsSync(`${source}.sha256`) ? `${source}.sha256` : null, signature: existsSync(`${source}.sig`) ? `${source}.sig` : null } })
204
+ if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command: `omakit lab setup --from <path to ${release.fileName}>` })
205
+ else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir, release.fileName), sidecars: { checksum: existsSync(`${source}.sha256`) ? `${source}.sha256` : null, signature: existsSync(`${source}.sig`) ? `${source}.sig` : null } })
83
206
  } else if (download.present) {
84
207
  steps.push({ kind: "verify", file: download.iso, bytes: download.bytes })
85
208
  } else {
86
- steps.push({ kind: "download", url: pin.release.isoUrl, bytes: pin.release.bytes, resumeFrom: download.partial || 0, to: join(download.dir, pin.release.fileName) })
209
+ steps.push({ kind: "download", url: release.isoUrl, bytes: release.bytes, resumeFrom: download.partial || 0, to: join(download.dir, release.fileName) })
87
210
  }
88
- steps.push({ kind: "sidecars", bytes: 203, urls: [pin.release.checksumUrl, pin.release.signatureUrl], to: download.dir })
211
+ steps.push({ kind: "sidecars", bytes: 203, urls: [release.checksumUrl, release.signatureUrl], to: download.dir })
89
212
  }
90
213
  if (base.state !== "ready") {
91
- for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
92
- // A base the toolchain built but a verification boot never promoted
93
- // (an interrupted or failed setup) is verified and promoted, not
94
- // rebuilt: six minutes and six gigabytes are not spent twice.
95
- const staged = stagedBases(layout)
214
+ // A base the toolchain built for this release but a verification boot
215
+ // never promoted (an interrupted or failed setup) is verified and
216
+ // promoted, not rebuilt: six minutes and six gigabytes are not spent
217
+ // twice. One built from another release is left for prune.
218
+ const staged = stagedBases(layout, release)
219
+ buildBlockers(staged.length === 0)
220
+ const replacing = base.state === "missing" ? null : base
221
+ const superseded = supersededDownloads(layout, release)
96
222
  if (staged.length) {
97
- steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing: base.state === "missing" ? null : base })
223
+ steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing, superseded })
98
224
  } else {
99
- for (const line of probeCommands(BUILD_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
100
- if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
101
- steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing: base.state === "missing" ? null : base, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
225
+ steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing, superseded, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
102
226
  }
103
227
  }
104
- if (plugins) {
105
- const pinDir = marketplacePinDir(repoRoot)
106
- const wanted = []
107
- for (const id of WEIGH_LISTED) {
108
- const listing = listedPlugin(pinDir, id)
109
- if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
110
- else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
111
- }
112
- if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
113
- }
228
+ addPlugins()
114
229
  const needed = steps.reduce((sum, step) => sum + (step.kind === "download" ? step.bytes - (step.resumeFrom || 0) : step.kind === "import" ? step.bytes : step.kind === "build" ? step.bytes : 0), 0)
115
230
  if (steps.length && free.bytes < needed) blockers.push({ what: "disk", cost: `${bytesBoth(free.bytes)} free at ${free.path}; this setup needs ${bytesBoth(needed)}`, command: "omakit lab prune" })
116
- return { layout, pin, download, base, toolchain, free, steps, blockers, needed, afterBytes: pin.measured.preparedLabBytes }
231
+ // Afterwards: this ISO and a base the size the measured release's was.
232
+ return { layout, pin, newest, download, base, toolchain, free, steps, blockers, needed, afterBytes: release.bytes + pin.measured.baseDirectoryBytes }
117
233
  }
118
234
 
119
- /** Staged bases awaiting verification: `staging/base-*` with the four files and no manifest, oldest first. */
120
- export function stagedBases(layout) {
235
+ /**
236
+ * Staged bases awaiting verification: `staging/base-*` with the four files
237
+ * and no manifest, oldest first. With a release, only the ones built from
238
+ * its ISO: the build record names the ISO it was built from, and a base of
239
+ * another release would never pass the verification boot's guest check.
240
+ */
241
+ export function stagedBases(layout, release = null) {
121
242
  if (!existsSync(layout.staging)) return []
122
243
  return readdirSync(layout.staging).filter((name) => /^base-\d{8}-\d{6}$/.test(name)).sort()
123
244
  .map((name) => join(layout.staging, name))
124
245
  .filter((dir) => [BASE_FILES.disk, BASE_FILES.vars, BASE_FILES.key, BASE_FILES.publicKey].every((file) => existsSync(join(dir, file))) && !existsSync(join(dir, BASE_FILES.manifest)))
246
+ .filter((dir) => {
247
+ if (!release) return true
248
+ const record = readJson(join(dir, "build", "lab-build.json"))
249
+ return record?.release?.sha256 === release.sha256 || String(record?.iso || "").includes(`/downloads/${release.sha256}/`)
250
+ })
125
251
  }
126
252
 
127
253
  /** The lines of the disclosure, exactly as the contract in docs/LAB.md shows them; the consent question follows them. */
@@ -132,24 +258,48 @@ export function disclosureLines(plan) {
132
258
  const imported = plan.steps.find((step) => step.kind === "import")
133
259
  const build = plan.steps.find((step) => step.kind === "build")
134
260
  const plugins = plan.steps.find((step) => step.kind === "plugins")
135
- lines.push(["Omarchy", `release ${pin.release.name}; installed guest expected ${pin.release.expectedGuestVersion}`])
261
+ // Plugins alone: the base stays as it is, and the lines say why.
262
+ if (!plan.steps.some((step) => ["download", "import", "verify", "sidecars", "build", "promote"].includes(step.kind))) {
263
+ const baseName = plan.base?.manifest?.release?.name
264
+ lines.push(["Omarchy", plan.stale
265
+ ? `the base there, Omarchy ${plan.stale.base}, left as it is: the newest release could not be looked up (${plan.stale.reason})`
266
+ : plan.ahead
267
+ ? `the base there, Omarchy ${plan.ahead.base}, kept: newer than ${plan.ahead.release}`
268
+ : `the base there, Omarchy ${baseName || pin.release?.name}, ${plan.newest?.checked ? "the newest release" : "left as it is"}`])
269
+ if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
270
+ lines.push(["store", plan.layout.cache])
271
+ lines.push(["on disk", `${bytesBoth(plan.free.bytes)} free now`])
272
+ return lines
273
+ }
274
+ const found = plan.newest?.checked
275
+ ? `the newest published (${plan.newest.tag}${plan.newest.publishedAt ? `, ${plan.newest.publishedAt.slice(0, 10)}` : ""}, found now in ${plan.newest.list.replace(/^https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+)\/releases.*$/, "github.com/$1")})`
276
+ : `from the file named by --from; the newest could not be looked up (${plan.newest?.reason || "not asked"})`
277
+ lines.push(["Omarchy", `release ${pin.release.name}, ${found}; the guest will run omarchy ${pin.release.name}`])
278
+ for (const skipped of plan.newest?.skipped || []) lines.push(["newer", `${skipped.name} is tagged and passed over: ${skipped.reason}`])
136
279
  if (download) lines.push(["download", `${bytesBoth(download.bytes - download.resumeFrom)}${download.resumeFrom ? ` (resuming at ${download.resumeFrom.toLocaleString("en-US")} of ${download.bytes.toLocaleString("en-US")} B)` : ""}`], ["from", download.url])
137
280
  else if (imported) lines.push(["download", "0 B: the file named by --from is copied and verified"], ["from", imported.from])
138
281
  else lines.push(["download", "0 B: the ISO on disk is verified"])
139
- lines.push(["verify", `pinned SHA-256 ${pin.release.sha256} and the Omarchy signature ${pin.release.signingFingerprint}`])
282
+ lines.push(["verify", `SHA-256 ${pin.release.sha256} as published beside it, and the Omarchy signature ${pin.release.signingFingerprint}, the key omakit ships`])
140
283
  lines.push(["store", plan.layout.cache])
141
284
  const promote = plan.steps.find((step) => step.kind === "promote")
285
+ const replacing = (step, what) => {
286
+ const parts = []
287
+ if (step.replacing) parts.push(`the ${step.replacing.state} base there${step.replacing.manifest ? ` (Omarchy ${step.replacing.manifest.release.name}, ${bytesBoth(step.replacing.allocatedBytes)})` : ` (${bytesBoth(step.replacing.allocatedBytes)})`}`)
288
+ const older = step.superseded || []
289
+ if (older.length) parts.push(`${older.length === 1 ? "one older download" : `${older.length} older downloads`}${older.some((entry) => entry.name) ? ` (${older.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")})` : ""}, ${bytesBoth(older.reduce((sum, entry) => sum + entry.bytes, 0))}`)
290
+ if (parts.length) lines.push(["replacing", `${parts.join(", and ")}, removed after ${what} verifies; until then a run uses what is there`])
291
+ }
142
292
  if (build) {
143
- lines.push(["build", `${durationWords(build.milliseconds)} on the reference host (M14); download excluded`])
144
- if (build.replacing) lines.push(["replacing", `the ${build.replacing.state} base there (${bytesBoth(build.replacing.allocatedBytes)}), removed after the new one verifies`])
293
+ lines.push(["build", `${durationWords(build.milliseconds)} measured with Omarchy ${pin.measured.release} on the reference host (M14); download excluded`])
294
+ replacing(build, "the new base")
145
295
  }
146
296
  if (promote) {
147
297
  lines.push(["build", `none: the base the toolchain built at ${promote.staged} is booted once, verified and promoted`])
148
- if (promote.replacing) lines.push(["replacing", `the ${promote.replacing.state} base there (${bytesBoth(promote.replacing.allocatedBytes)}), removed after the staged one verifies`])
298
+ replacing(promote, "the staged base")
149
299
  }
150
300
  if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
151
301
  lines.push(["afterwards", "verified ISO, one immutable base, and manifests"])
152
- lines.push(["on disk", `${bytesBoth(plan.afterBytes)} (M14), before evidence; ${bytesBoth(plan.free.bytes)} free now`])
302
+ lines.push(["on disk", `about ${bytesBoth(plan.afterBytes)}: this ISO and a base the size Omarchy ${pin.measured.release}'s measured (M14), before evidence; ${bytesBoth(plan.free.bytes)} free now`])
153
303
  return lines
154
304
  }
155
305
 
@@ -157,8 +307,8 @@ export function disclosureLines(plan) {
157
307
  export const CONSENT_QUESTION = "Acquire and build this verified base now?"
158
308
 
159
309
  /**
160
- * A resumable literal GET of the pinned object to `<to>.part`. The byte
161
- * count is checked against the pin as it arrives and at the end; a
310
+ * A resumable literal GET of the release's ISO to `<to>.part`. The byte
311
+ * count is checked against the release's as it arrives and at the end; a
162
312
  * server that answers 200 to a range request restarts the file.
163
313
  */
164
314
  export async function downloadRelease({ url, to, bytes, onProgress = () => {}, signal, fetchStream = getStream }) {
@@ -172,6 +322,7 @@ export async function downloadRelease({ url, to, bytes, onProgress = () => {}, s
172
322
  try {
173
323
  response = await fetchStream(url, { rangeFrom: have, signal })
174
324
  } catch (error) {
325
+ if (error?.code === "interrupted") throw new LabError("interrupted", "interrupted; the partial download stays and resumes next time", { remedy: "omakit lab setup: the download resumes where it stopped" })
175
326
  if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again; the download resumes" })
176
327
  throw error
177
328
  }
@@ -179,32 +330,51 @@ export async function downloadRelease({ url, to, bytes, onProgress = () => {}, s
179
330
  const expected = append ? bytes - have : bytes
180
331
  const length = Number(response.headers.get("content-length"))
181
332
  if (Number.isFinite(length) && length > 0 && length !== expected) {
182
- throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where the pin expects ${expected.toLocaleString("en-US")}; the object at the versioned URL is not the pinned release`)
333
+ throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where ${expected.toLocaleString("en-US")} were expected; the object at the versioned URL changed since the release was read`, { remedy: "omakit lab setup: the release is read again" })
183
334
  }
184
335
  const out = createWriteStream(part, { flags: append ? "a" : "w", mode: 0o600 })
185
336
  let written = append ? have : 0
186
337
  const started = Date.now()
338
+ const interrupted = () => new LabError("interrupted", "interrupted; the partial download stays and resumes next time", { remedy: "omakit lab setup: the download resumes where it stopped" })
187
339
  try {
188
340
  for await (const chunk of response.body) {
189
- if (signal?.aborted) throw new LabError("interrupted", "interrupted; the partial download stays and resumes next time")
341
+ if (signal?.aborted) throw interrupted()
190
342
  written += chunk.length
191
- if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the pinned ${bytes.toLocaleString("en-US")} B`)
343
+ if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the ${bytes.toLocaleString("en-US")} B it announced`)
192
344
  if (!out.write(chunk)) await new Promise((resolvePromise) => out.once("drain", resolvePromise))
193
345
  onProgress(written, bytes, Date.now() - started)
194
346
  }
347
+ } catch (error) {
348
+ // An abort while the body streams ends the loop with the abort's own
349
+ // reason ("SIGINT", a bare string), not with the check above: it is an
350
+ // interrupt, said as one, with the exit status the signal gives.
351
+ if (signal?.aborted && !(error instanceof LabError)) throw interrupted()
352
+ throw error
195
353
  } finally {
196
354
  await new Promise((resolvePromise) => out.end(resolvePromise))
197
355
  }
198
- if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of the pinned ${bytes.toLocaleString("en-US")} B; run setup again to resume`)
356
+ if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of ${bytes.toLocaleString("en-US")} B; run setup again to resume`)
199
357
  return { part, bytes: written, milliseconds: Date.now() - started }
200
358
  }
201
359
 
202
360
  /** The two sidecars, small, to the download directory; the signature is required, the checksum compared. */
203
361
  async function fetchSidecars({ pin, dir, layout, fetchStream = getStream, signal }) {
204
362
  for (const [url, name] of [[pin.release.checksumUrl, `${pin.release.fileName}.sha256`], [pin.release.signatureUrl, `${pin.release.fileName}.sig`]]) {
205
- const response = await fetchStream(url, { signal })
206
- const body = Buffer.from(await response.arrayBuffer())
207
- if (body.length > 4096) throw new LabError("sidecar-mismatch", `${url} answered ${body.length} B; a sidecar is a few dozen`)
363
+ let response
364
+ try {
365
+ response = await fetchStream(url, { signal })
366
+ } catch (error) {
367
+ if (error?.code === "interrupted") throw new LabError("interrupted", "interrupted while fetching the checksum and signature; what was downloaded stays", { remedy: "omakit lab setup: it picks up where it stopped" })
368
+ if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again" })
369
+ throw error
370
+ }
371
+ let body
372
+ try {
373
+ body = await smallBody(response, url, "sidecar-mismatch")
374
+ } catch (error) {
375
+ if (signal?.aborted && !(error instanceof LabError)) throw new LabError("interrupted", "interrupted while fetching the checksum and signature; what was downloaded stays", { remedy: "omakit lab setup: it picks up where it stopped" })
376
+ throw error
377
+ }
208
378
  writeFileSync(inLab(layout.cache, `downloads/${pin.release.sha256}/${name}`), body, { mode: 0o600 })
209
379
  }
210
380
  }
@@ -216,16 +386,50 @@ async function verifyAndPromote({ candidate, to, pin, layout, stagingRoot, onPro
216
386
  const checksum = `${to}.sha256`
217
387
  const judged = judgeRelease({ file: candidate, signature: existsSync(signature) ? signature : null, checksum: existsSync(checksum) ? checksum : null, pin, stagingRoot, onProgress })
218
388
  if (!judged.ok) {
219
- throw new LabError("iso-mismatch", `the file at ${candidate} is not the pinned release: ${judged.reason}; it stays where it is and nothing will boot it`, { remedy: "remove it with `omakit lab prune`, then `omakit lab setup` again" })
389
+ throw new LabError("iso-mismatch", `the file at ${candidate} is not Omarchy ${pin.release.name} as published: ${judged.reason}; it stays where it is and nothing will boot it`, { remedy: "remove it with `omakit lab prune`, then `omakit lab setup` again" })
220
390
  }
221
- if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the published checksum sidecar names ${judged.sidecarSha256}, the pin ${pin.release.sha256}: the object at the versioned URL was replaced, and this file, which matches the pin, is not what the site publishes now`, { remedy: "a pin update is a reviewed change; docs/LAB.md says how" })
391
+ if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the checksum published now names ${judged.sidecarSha256}, and ${pin.release.sha256} when setup read the release: Omarchy republished ${pin.release.fileName} while setup ran, and this file is the earlier one`, { remedy: "omakit lab setup: it reads the release again and fetches what is published now" })
222
392
  if (candidate !== to) await moveIntoLab(layout.cache, candidate, `downloads/${pin.release.sha256}/${pin.release.fileName}`)
223
393
  chmodSync(to, 0o444)
224
394
  const st = statSync(to)
225
- writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
395
+ writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, name: pin.release.name, fileName: pin.release.fileName, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
226
396
  return { file: to, judged, dir }
227
397
  }
228
398
 
399
+ /**
400
+ * Stop what a build left running under `stagingDir` (inspect.mjs
401
+ * buildGuests): SIGTERM, which QEMU takes as a clean shutdown, then SIGKILL
402
+ * after fifteen seconds. Runs after the driver exits for any reason, an
403
+ * interrupt included, so no guest outlives the setup that started it.
404
+ */
405
+ export async function stopBuildGuests(stagingDir, onLine = () => {}) {
406
+ for (const guest of buildGuests(stagingDir)) {
407
+ try {
408
+ process.kill(guest.pid, "SIGTERM")
409
+ } catch {
410
+ continue
411
+ }
412
+ const deadline = Date.now() + 15_000
413
+ let alive = true
414
+ while (alive && Date.now() < deadline) {
415
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250))
416
+ try {
417
+ process.kill(guest.pid, 0)
418
+ } catch {
419
+ alive = false
420
+ }
421
+ }
422
+ if (alive) {
423
+ try {
424
+ process.kill(guest.pid, "SIGKILL")
425
+ } catch {
426
+ // Gone between the last check and this one.
427
+ }
428
+ }
429
+ onLine({ state: "info", text: `stopped the build's QEMU (pid ${guest.pid}), which the driver left running` })
430
+ }
431
+ }
432
+
229
433
  /**
230
434
  * Build the base with the toolchain's console driver, verify it by
231
435
  * booting it, and promote it. The driver runs from a copy under staging,
@@ -269,6 +473,7 @@ async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine,
269
473
  })
270
474
  })
271
475
  const buildMilliseconds = Date.now() - started
476
+ await stopBuildGuests(stagingDir, onLine)
272
477
  if (status !== 0) throw new LabError(signal?.aborted ? "interrupted" : "build-failed", `the toolchain's install driver exited ${status === null ? "on a signal" : status} after ${durationWords(buildMilliseconds)}; its log is ${join(stagingDir, "build.log")}, and nothing was promoted`, { remedy: "read the log, then `omakit lab prune` to remove the staging directory and `omakit lab setup` to build again" })
273
478
  const built = join(stagingDir, "test-runs", pin.release.fileName.replace(/\.iso$/, ""))
274
479
  for (const name of ["base.qcow2", "OVMF_VARS.4m.fd", "id_ed25519", "id_ed25519.pub"]) {
@@ -285,7 +490,7 @@ async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine,
285
490
  removeFromLab(layout.cache, `staging/${buildId}`)
286
491
  // The measured duration travels with the staged base, so a promotion
287
492
  // after an interrupted setup still records it.
288
- writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, builtAt: new Date().toISOString() })
493
+ writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, release: { name: pin.release.name, sha256: pin.release.sha256 }, builtAt: new Date().toISOString() })
289
494
  return { staged, baseId, buildMilliseconds, harnessSha256: sha256, origin: `built ${new Date().toISOString()} by the pinned toolchain harness ${sha256.slice(0, 12)} from the verified ISO` }
290
495
  }
291
496
 
@@ -294,8 +499,8 @@ async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMillisec
294
499
  onPhase("booting the staged base once to read the installed omarchy package")
295
500
  const booted = await withGuest({ baseDir: staged, stagingName: `${baseId}-verify`, logFile: join(staged, "verify-boot.log"), layout, pin, onPhase, signal }, async () => null)
296
501
  const version = booted.identity?.version
297
- if (version !== pin.release.expectedGuestVersion) {
298
- throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"}); the pin expects ${pin.release.expectedGuestVersion}; the base stays in staging and is not promoted`, { remedy: "`omakit lab prune` removes it; a pin update is a reviewed change, docs/LAB.md says how" })
502
+ if (!guestIsRelease(version, pin.release)) {
503
+ throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"}), not Omarchy ${pin.release.name}'s package; the base stays in staging and is not promoted`, { remedy: "`omakit lab prune` removes it; an ISO that installs another release's package is not used as that release" })
299
504
  }
300
505
  if (booted.identity.linked) throw new LabError("guest-mismatch", `the staged base is dev-linked to ${booted.identity.omarchyPath}; a base runs the installed package`)
301
506
  onPhase("hashing the base disk and the firmware template")
@@ -310,7 +515,7 @@ async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMillisec
310
515
  const manifest = {
311
516
  schema: 1,
312
517
  state: "ready",
313
- release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl, embeddedBuild: pin.release.embeddedBuild, volume: pin.release.volume, iso },
518
+ release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl, iso },
314
519
  signature: { fingerprint: pin.release.signingFingerprint },
315
520
  guest: { version, omarchyPackage: booted.identity.omarchyPackage, kernel: booted.identity.kernel, hostname: booted.identity.hostname, readBy: "pacman -Q omarchy over SSH in the verification boot" },
316
521
  disk: { file: BASE_FILES.disk, bytes: diskHash.bytes, sha256: diskHash.sha256, virtualBytes: qemuInfo?.["virtual-size"] ?? null, allocatedBytes: allocatedBytes(disk), format: qemuInfo?.format ?? null },
@@ -359,6 +564,23 @@ function fetchPlugins({ plugins, layout, onPhase }) {
359
564
  return fetched
360
565
  }
361
566
 
567
+ /**
568
+ * After a new base is promoted: the downloads of older releases the plan
569
+ * named, each checked again against the release's own digest so the one
570
+ * just verified is never among them. Nothing is removed before the
571
+ * promotion, so a build that fails leaves the old release usable.
572
+ */
573
+ export function removeSuperseded({ layout, pin, step, onLine = () => {} }) {
574
+ const removed = []
575
+ for (const entry of step.superseded || []) {
576
+ if (entry.digest === pin.release.sha256 || !existsSync(join(layout.cache, entry.relative))) continue
577
+ removeFromLab(layout.cache, entry.relative)
578
+ removed.push(entry)
579
+ }
580
+ if (removed.length) onLine({ state: "pass", text: `removed ${removed.length === 1 ? "the older download" : `${removed.length} older downloads`} (${removed.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")}), ${bytesBoth(removed.reduce((sum, entry) => sum + entry.bytes, 0))}` })
581
+ return removed
582
+ }
583
+
362
584
  /**
363
585
  * Execute a plan after consent. `consented` is the caller's statement
364
586
  * that the disclosure was shown and answered yes, or that --yes was
@@ -374,9 +596,18 @@ export async function setupLab({ plan, consented, onPhase = () => {}, onLine = (
374
596
  await acquireLock(layout, { runId: `setup-${stampNow()}`, pid: process.pid, qmpSocket: null, startedAt: new Date().toISOString() })
375
597
  const lockId = readJson(join(layout.lock, "holder.json"))?.runId
376
598
  try {
377
- labDir(layout.cache, "downloads", pin.release.sha256)
599
+ // With the lock held, no other setup is building, so a build QEMU still
600
+ // running under staging belongs to one that was killed before it could
601
+ // stop it (SIGKILL, a crash): an orphan, stopped here before anything else.
602
+ if (existsSync(layout.staging)) {
603
+ for (const name of readdirSync(layout.staging).filter((entry) => /^build-\d{8}-\d{6}$/.test(entry)).sort()) await stopBuildGuests(join(layout.staging, name), onLine)
604
+ }
605
+ // A download directory only for a plan that fetches or imports: a
606
+ // plugins-only plan over a kept base leaves none behind for a release
607
+ // it never fetched.
608
+ if (plan.steps.some((step) => ["download", "import", "sidecars"].includes(step.kind))) labDir(layout.cache, "downloads", pin.release.sha256)
378
609
  labDir(layout.cache, "staging")
379
- const target = join(downloadDir(layout, pin), pin.release.fileName)
610
+ const target = pin.release ? join(downloadDir(layout, pin), pin.release.fileName) : null
380
611
  for (const step of plan.steps) {
381
612
  if (signal?.aborted) throw new LabError("interrupted", "interrupted; what was verified stays verified, what was not stays unverified")
382
613
  if (step.kind === "download") {
@@ -410,14 +641,16 @@ export async function setupLab({ plan, consented, onPhase = () => {}, onLine = (
410
641
  const built = await buildBase({ pin, layout, toolchainHarness: step.toolchain, iso: target, onPhase, onLine, signal, repoRoot })
411
642
  onLine({ state: "pass", text: `the toolchain built a base in ${durationWords(built.buildMilliseconds)}` })
412
643
  const promoted = await verifyAndPromoteBase({ ...built, pin, layout, iso: target, onPhase, signal })
413
- done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
644
+ done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes, release: pin.release.name })
414
645
  onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
646
+ done.push({ kind: "superseded", removed: removeSuperseded({ layout, pin, step, onLine }) })
415
647
  } else if (step.kind === "promote") {
416
648
  const baseId = step.staged.split("/").at(-1)
417
649
  const buildRecord = readJson(join(step.staged, "build", "lab-build.json"))
418
650
  const promoted = await verifyAndPromoteBase({ staged: step.staged, baseId, pin, layout, buildMilliseconds: buildRecord?.buildMilliseconds ?? null, origin: `built ${buildRecord?.builtAt || "earlier"} by the pinned toolchain harness ${(buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256).slice(0, 12)} from the verified ISO, promoted ${new Date().toISOString()} after a verification boot`, harnessSha256: buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256, iso: target, onPhase, signal })
419
- done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
651
+ done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes, release: pin.release.name })
420
652
  onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
653
+ done.push({ kind: "superseded", removed: removeSuperseded({ layout, pin, step, onLine }) })
421
654
  } else if (step.kind === "plugins") {
422
655
  const fetched = fetchPlugins({ plugins: step.plugins, layout, onPhase })
423
656
  done.push({ kind: "plugins", fetched })