omakit 0.6.8 → 0.6.10

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.
@@ -0,0 +1,178 @@
1
+ // The newest Omarchy release the lab can prepare, found each time it is
2
+ // asked: the release list on GitHub, newest first by version, and for each
3
+ // the three objects the ISO host must publish whole (the ISO, its .sha256
4
+ // and its detached .sig). Only GETs, through github.mjs's one call site;
5
+ // the ISO's size is read from a one-byte range request, so no image is
6
+ // fetched here.
7
+ //
8
+ // What it will not do, each measured against a way the lab went wrong or
9
+ // could:
10
+ // - Stay on an old release unnoticed. 4.0.4 was published on 2026-09-15
11
+ // and the lab, pinned to 4.0.3 on 2026-09-18, still offered 4.0.3 on
12
+ // 2026-09-23; nothing looked.
13
+ // - Fall back past a release that could not be read. A newer release is
14
+ // skipped only when the host answers that an object is not there (404:
15
+ // tagged, not yet published, as 4.0.0 has no .sha256). Everything else
16
+ // stops the search with its reason: a timeout, a 5xx, a checksum that
17
+ // does not read, an ISO whose size is not announced. A fallback on any
18
+ // of those is the stale lab again.
19
+ // - Take a release older than the floor, whatever the list says.
20
+ // - Accept a signer it does not ship. The .sig names its issuers; one with
21
+ // none of them the pinned fingerprint stops the search before the ISO is
22
+ // asked for, rather than falling back to an older release the old key
23
+ // signed. A .sig with the pinned key among its signers (a rotation that
24
+ // signs with both) is the pinned key's.
25
+
26
+ import { getJson, getStream } from "../marketplace/github.mjs"
27
+ import { compareVersions, labPin, releaseOf, VERSION } from "./pin.mjs"
28
+ import { signatureIssuers, signedByPinned } from "./verify.mjs"
29
+ import { LabError } from "./run.mjs"
30
+
31
+ /** A sidecar is a few dozen bytes; anything past this is not one. */
32
+ const SIDECAR_LIMIT = 4096
33
+
34
+ /** A GET that answers 404 is "not published"; every other failure is thrown as it came. */
35
+ async function read(fetchStream, url, signal) {
36
+ try {
37
+ return await fetchStream(url, { signal })
38
+ } catch (error) {
39
+ if (error?.code === "not-found" || error?.status === 404) return null
40
+ throw error
41
+ }
42
+ }
43
+
44
+ /**
45
+ * A sidecar's body, refused past SIDECAR_LIMIT as announced or as it
46
+ * streams, never buffered whole first; `code` names the refusal for the
47
+ * caller (the search's release-unavailable, setup's sidecar-mismatch).
48
+ */
49
+ export async function smallBody(response, url, code = "release-unavailable") {
50
+ const tooBig = (bytes) => new LabError(code, `${url} answered ${bytes.toLocaleString("en-US")} B or more; a sidecar is a few dozen`)
51
+ const announced = Number(response.headers?.get?.("content-length"))
52
+ if (Number.isFinite(announced) && announced > SIDECAR_LIMIT) {
53
+ try {
54
+ await response.body?.cancel()
55
+ } catch {
56
+ // Nothing left to cancel.
57
+ }
58
+ throw tooBig(announced)
59
+ }
60
+ if (!response.body?.getReader) {
61
+ const body = Buffer.from(await response.arrayBuffer())
62
+ if (body.length > SIDECAR_LIMIT) throw tooBig(body.length)
63
+ return body
64
+ }
65
+ const reader = response.body.getReader()
66
+ const chunks = []
67
+ let size = 0
68
+ for (;;) {
69
+ const { done, value } = await reader.read()
70
+ if (done) break
71
+ size += value.length
72
+ if (size > SIDECAR_LIMIT) {
73
+ await reader.cancel()
74
+ throw tooBig(size)
75
+ }
76
+ chunks.push(Buffer.from(value))
77
+ }
78
+ return Buffer.concat(chunks)
79
+ }
80
+
81
+ /** The ISO's full size from a ranged answer (`Content-Range: bytes 0-0/<size>`), or from Content-Length when the host sent the whole object. */
82
+ function announcedSize(response) {
83
+ const range = String(response.headers.get("content-range") || "").match(/^bytes\s+\d+-\d+\/(\d+)$/)
84
+ if (response.status === 206) return range ? Number(range[1]) : NaN
85
+ return Number(response.headers.get("content-length"))
86
+ }
87
+
88
+ /**
89
+ * The published digest from a `.sha256` sidecar: `<64 hex> <file name>`,
90
+ * the name the ISO's own. Null for anything else.
91
+ */
92
+ export function sidecarDigest(text, fileName) {
93
+ const match = String(text).trim().match(/^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$/)
94
+ if (!match) return null
95
+ if (match[2].split("/").at(-1) !== fileName) return null
96
+ return match[1].toLowerCase()
97
+ }
98
+
99
+ /**
100
+ * One release checked on the ISO host, in the order that stops earliest:
101
+ * the checksum, the signature (whose signer is checked before the ISO is
102
+ * asked for, so a release omakit would refuse is never requested), then
103
+ * the ISO's announced size. `{ ok: true, release, signers }` when the three
104
+ * are there and read as they should; `{ ok: false, reason }` when the host
105
+ * says one is not published. A signer other than the pinned one throws
106
+ * `signer-changed`; any other failure throws as it came.
107
+ */
108
+ export async function probeRelease({ pin, name, fetchStream = getStream, signal }) {
109
+ const shape = releaseOf(pin, { name, bytes: 0, sha256: "0".repeat(64) })
110
+ const checksum = await read(fetchStream, shape.checksumUrl, signal)
111
+ if (!checksum) return { ok: false, reason: `${shape.fileName}.sha256 is not published` }
112
+ const sha256 = sidecarDigest((await smallBody(checksum, shape.checksumUrl)).toString("utf8"), shape.fileName)
113
+ if (!sha256) throw new LabError("release-unavailable", `${shape.checksumUrl} is published but does not name ${shape.fileName} with a SHA-256; the lab will not fall back past it to an older release`)
114
+ const signature = await read(fetchStream, shape.signatureUrl, signal)
115
+ if (!signature) return { ok: false, reason: `${shape.fileName}.sig is not published` }
116
+ const signers = signatureIssuers(await smallBody(signature, shape.signatureUrl))
117
+ if (signedByPinned(signers, pin.releases.signingFingerprint) === false) {
118
+ const named = signers.map((signer) => signer.fingerprint || `key ${signer.keyId}`).join(" and ")
119
+ throw new LabError("signer-changed", `Omarchy ${name} is signed by ${named}, not the key omakit ships (${pin.releases.signingFingerprint}); omakit will not prepare it, and will not fall back to an older release the old key signed`, { remedy: "omakit upgrade: a newer omakit carries the new key once it is verified" })
120
+ }
121
+ // One byte of the image, for its size in Content-Range; the rest is never asked for.
122
+ const iso = await read((url, options) => fetchStream(url, { ...options, rangeEnd: 0 }), shape.isoUrl, signal)
123
+ if (!iso) return { ok: false, reason: `${shape.fileName} is not published` }
124
+ const bytes = announcedSize(iso)
125
+ try {
126
+ await iso.body?.cancel()
127
+ } catch {
128
+ // A body that is already closed has nothing left to cancel.
129
+ }
130
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) throw new LabError("release-unavailable", `${shape.isoUrl} is published but its size is not announced; the lab will not fall back past it to an older release`)
131
+ return { ok: true, release: releaseOf(pin, { name, bytes, sha256 }), signers }
132
+ }
133
+
134
+ /**
135
+ * The newest release at or above the floor that is published whole.
136
+ * Returns `{ release, tag, publishedAt, checkedAt, list, skipped }`, where
137
+ * `skipped` names each newer release passed over and why. Throws a
138
+ * LabError: `release-unavailable` when none of the newest `candidates`
139
+ * is published whole, `signer-changed` when the newest is signed by
140
+ * another key; a network error comes through with its own code.
141
+ */
142
+ export async function findNewestRelease({ pin = labPin(), readJson = getJson, fetchStream = getStream, signal, now = () => new Date() } = {}) {
143
+ const listed = await readJson(pin.releases.list, { signal })
144
+ if (!Array.isArray(listed)) throw new LabError("release-unavailable", `${pin.releases.list} did not answer with a list of releases`)
145
+ const seen = new Set()
146
+ const releases = listed
147
+ .filter((entry) => entry && typeof entry === "object" && !entry.draft && !entry.prerelease && typeof entry.tag_name === "string")
148
+ .map((entry) => ({ tag: entry.tag_name, name: entry.tag_name.replace(/^v/, ""), publishedAt: entry.published_at || null }))
149
+ .filter((entry) => VERSION.test(entry.name) && !seen.has(entry.name) && seen.add(entry.name))
150
+ .sort((a, b) => compareVersions(b.name, a.name))
151
+ const eligible = releases.filter((entry) => compareVersions(entry.name, pin.releases.floor) >= 0)
152
+ if (!eligible.length) {
153
+ throw new LabError("release-unavailable", `${pin.releases.list} lists no release at or above ${pin.releases.floor}${releases.length ? `; the newest listed is ${releases[0].name}` : ""}`)
154
+ }
155
+ const skipped = []
156
+ for (const entry of eligible.slice(0, pin.releases.candidates)) {
157
+ const probed = await probeRelease({ pin, name: entry.name, fetchStream, signal })
158
+ if (!probed.ok) {
159
+ skipped.push({ name: entry.name, reason: probed.reason })
160
+ continue
161
+ }
162
+ return { release: probed.release, tag: entry.tag, publishedAt: entry.publishedAt, checkedAt: now().toISOString(), list: pin.releases.list, skipped }
163
+ }
164
+ throw new LabError("release-unavailable", `none of the newest ${Math.min(eligible.length, pin.releases.candidates)} releases is published whole: ${skipped.map((entry) => `${entry.name} (${entry.reason})`).join("; ")}`)
165
+ }
166
+
167
+ /**
168
+ * The newest-release check as a value, never a throw: what inspect, doctor
169
+ * and a run print. `{ checked: true, ... }` with the release, or
170
+ * `{ checked: false, code, reason }`.
171
+ */
172
+ export async function checkNewestRelease(options = {}) {
173
+ try {
174
+ return { checked: true, ...(await findNewestRelease(options)) }
175
+ } catch (error) {
176
+ return { checked: false, code: error?.code || "error", reason: error?.message || String(error) }
177
+ }
178
+ }
@@ -30,25 +30,67 @@ export function labDoctorChecks(lab) {
30
30
  // To the GiB: the exact figure moves with every write on the filesystem,
31
31
  // and doctor's two consecutive runs are held to the same bytes (a tenth
32
32
  // of a GiB flipped once while another test packed a tarball beside it).
33
- add("lab.disk", enough, `${Math.floor(lab.free.bytes / 2 ** 30).toLocaleString("en-US")} GiB free on the lab cache's filesystem; a prepared lab measured ${bytesBoth(lab.pin.measured.preparedLabBytes)} (M14)`, enough ? null : "omakit lab prune, or free the difference")
34
- add("lab.iso", lab.download.verified, `${lab.pin.release.name}, ${lab.download.reason}`, lab.download.verified ? null : "omakit lab setup", { sha256: lab.pin.release.sha256, present: lab.download.present, verified: lab.download.verified })
35
- add("lab.base", lab.base.state === "ready", `${lab.base.state}: ${lab.base.reason}`, lab.base.state === "ready" ? null : "omakit lab setup", { state: lab.base.state, release: lab.base.manifest?.release?.name ?? null, guestVersion: lab.base.manifest?.guest?.version ?? null, diskSha256: lab.base.manifest?.disk?.sha256 ?? null, allocatedBytes: lab.base.allocatedBytes, lockHeld: lab.lock.held, lockAlive: lab.lock.alive })
33
+ add("lab.disk", enough, `${Math.floor(lab.free.bytes / 2 ** 30).toLocaleString("en-US")} GiB free on the lab cache's filesystem; a prepared lab measured ${bytesBoth(lab.pin.measured.preparedLabBytes)} with Omarchy ${lab.pin.measured.release} (M14)`, enough ? null : "omakit lab prune, or free the difference")
34
+ checks.push(releaseCheck(lab))
35
+ const usable = ["ready", "outdated", "ahead"].includes(lab.base.state)
36
+ // The ISO is what a build needs: with a usable base there it is only
37
+ // advice when a build is ahead, and the release check says when that is.
38
+ const release = lab.pin.release
39
+ checks.push({
40
+ id: "lab.iso",
41
+ state: lab.download.verified ? "ok" : usable ? "info" : "advice",
42
+ detail: release ? `${release.name}, ${lab.download.reason}` : lab.download.reason,
43
+ action: lab.download.verified || usable ? null : "omakit lab setup",
44
+ evidence: { release: release?.name ?? null, sha256: release?.sha256 ?? null, present: lab.download.present, verified: lab.download.verified },
45
+ })
46
+ add("lab.base", usable, `${lab.base.state}: ${lab.base.reason}`, usable ? null : "omakit lab setup", { state: lab.base.state, release: lab.base.manifest?.release?.name ?? null, guestVersion: lab.base.manifest?.guest?.version ?? null, diskSha256: lab.base.manifest?.disk?.sha256 ?? null, allocatedBytes: lab.base.allocatedBytes, lockHeld: lab.lock.held, lockAlive: lab.lock.alive })
36
47
  if (lab.lock.held) add("lab.lock", !lab.lock.alive, lab.lock.alive ? `held by run ${lab.lock.record?.runId || "unknown"} (pid ${lab.lock.record?.pid || "?"})` : `a stale lock from ${lab.lock.record?.runId || "an unknown run"}`, lab.lock.alive ? "wait for the run" : "omakit lab prune")
37
48
  return checks
38
49
  }
39
50
 
51
+ /**
52
+ * Whether the lab is on the newest Omarchy release, one check: ok when the
53
+ * base is built from it, advice when a newer one is out, info when there
54
+ * is no base yet or the check was skipped (--offline), unknown when the
55
+ * release list could not be read.
56
+ */
57
+ export function releaseCheck(lab) {
58
+ const newest = lab.newest || { checked: false, code: "not-asked", reason: "the newest release was not looked up" }
59
+ const evidence = { newest: newest.checked ? newest.release.name : null, tag: newest.checked ? newest.tag : null, base: lab.base.manifest?.release?.name ?? null, code: newest.checked ? null : newest.code }
60
+ if (!newest.checked) {
61
+ if (newest.code === "offline") return { id: "lab.release", state: "info", detail: "the newest Omarchy release is not looked up (--offline)", action: null, evidence }
62
+ // Found, and refused: a finding, not a lookup that failed.
63
+ if (newest.code === "signer-changed") return { id: "lab.release", state: "advice", detail: newest.reason, action: "omakit upgrade: a newer omakit carries Omarchy's new key once it is verified", evidence }
64
+ return { id: "lab.release", state: "unknown", detail: `could not look up the newest Omarchy release: ${newest.reason}`, action: newest.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the checks that need it." : null, evidence }
65
+ }
66
+ const name = newest.release.name
67
+ if (lab.base.state === "ready") return { id: "lab.release", state: "ok", detail: `Omarchy ${name} is the newest release, and the lab's base is built from it`, action: null, evidence }
68
+ if (lab.base.state === "ahead") return { id: "lab.release", state: "info", detail: `the lab's base is Omarchy ${lab.base.manifest.release.name}, newer than ${name}, the newest release published whole now; it is kept`, action: null, evidence }
69
+ if (lab.base.state === "outdated") return { id: "lab.release", state: "advice", detail: `Omarchy ${name} is the newest release; the lab's base is ${lab.base.manifest.release.name}${lab.base.manifest.release.name === name ? " as first published" : ""}, and a run still uses it`, action: "omakit lab setup", evidence }
70
+ return { id: "lab.release", state: "info", detail: `Omarchy ${name} is the newest release; \`omakit lab setup\` prepares it`, action: null, evidence }
71
+ }
72
+
40
73
  /** `omakit lab inspect`. */
41
74
  export function renderLab(lab, { colour = colourEnabled(), env = process.env } = {}) {
42
75
  const c = styler(colour)
43
76
  const home = (text) => withHomeAbbreviated(text, env)
44
77
  const out = []
45
78
  const { release } = lab.pin
46
- out.push(...section("the release the lab is pinned to", c))
47
- out.push(...field("release", `Omarchy ${release.name}; guest package expected ${release.expectedGuestVersion}; ISO build ${release.embeddedBuild}, volume ${release.volume}`, c))
48
- out.push(...field("iso", release.isoUrl, c))
49
- out.push(...field("size", bytesBoth(release.bytes), c))
50
- out.push(...field("sha256", release.sha256, c, { wrapValue: false }))
51
- out.push(...field("signed by", `${release.signingFingerprint} (Omarchy <pkgs@omarchy.org>, the packaged key)`, c))
79
+ const newest = lab.newest || { checked: false, reason: "the newest release was not looked up" }
80
+ out.push(...section("the newest release", c))
81
+ if (newest.checked) {
82
+ out.push(...field("release", `Omarchy ${release.name} (${newest.tag}${newest.publishedAt ? `, published ${newest.publishedAt.slice(0, 10)}` : ""}), the newest in ${newest.list.replace(/^https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+)\/releases.*$/, "github.com/$1")} with its ISO, checksum and signature published`, c))
83
+ for (const skipped of newest.skipped || []) out.push(...field("passed over", `${skipped.name}: ${skipped.reason}`, c))
84
+ } else {
85
+ const why = newest.code === "signer-changed" ? `refused: ${newest.reason}` : `not looked up: ${newest.reason}`
86
+ out.push(...field("release", `${why}${release ? `; the lab is held to its base's release, Omarchy ${release.name}` : ""}`, c))
87
+ }
88
+ if (release) {
89
+ out.push(...field("iso", release.isoUrl, c))
90
+ out.push(...field("size", bytesBoth(release.bytes), c))
91
+ out.push(...field("sha256", release.sha256, c, { wrapValue: false }))
92
+ }
93
+ out.push(...field("signed by", `${lab.pin.releases.signingFingerprint} (Omarchy <pkgs@omarchy.org>, the key omakit ships); every release must carry it`, c))
52
94
  out.push(...field("toolchain", `omarchy-iso ${lab.pin.toolchain.commit.slice(0, 12)} with ${lab.pin.toolchain.patch}; harness sha256 ${shortId(lab.pin.toolchain.patchedHarnessSha256)}`, c))
53
95
  out.push("")
54
96
  out.push(...section("on disk", c))
@@ -61,7 +103,7 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
61
103
  out.push(...wrap(home(lab.toolchain.reason), { indent: GUTTER }, c))
62
104
  if (lab.staging.length) {
63
105
  out.push(`${mark(lab.staging.some((entry) => entry.alive) ? "info" : "advisory", c)}${c("name", "staging")}`)
64
- for (const entry of lab.staging) out.push(...wrap(home(`${entry.name}: ${bytesBoth(entry.allocatedBytes)}${entry.alive ? ", a QEMU answers on its socket" : ", inactive"}`), { indent: GUTTER }, c))
106
+ for (const entry of lab.staging) out.push(...wrap(home(`${entry.name}: ${bytesBoth(entry.allocatedBytes)}${entry.pids?.length ? `, a build's QEMU still runs (pid ${entry.pids.join(", ")})` : entry.alive ? ", a QEMU answers on its socket" : ", inactive"}`), { indent: GUTTER }, c))
65
107
  }
66
108
  if (lab.lock.held) {
67
109
  out.push(`${mark(lab.lock.alive ? "info" : "advisory", c)}${c("name", "lock")}`)
@@ -77,6 +119,13 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
77
119
  out.push(...wrap(line.reason, { indent: GUTTER }, c))
78
120
  }
79
121
  out.push("")
122
+ // Behind is said whether or not something else is missing: a host that
123
+ // is fixed next still has an older base, and hears it here first.
124
+ if (lab.behind) {
125
+ out.push(...field("behind", `Omarchy ${lab.behind.newest} is out and the base is ${lab.behind.base}: ${lab.behind.cost}`, c))
126
+ out.push(...action(lab.behind.command, c))
127
+ out.push("")
128
+ }
80
129
  if (lab.missing.length) {
81
130
  out.push(...section(`missing for \`omakit lab prove\`: ${lab.missing.length}`, c))
82
131
  for (const item of lab.missing) {
@@ -85,9 +134,12 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
85
134
  if (item.command) out.push(...action(home(item.command), c))
86
135
  }
87
136
  out.push("")
88
- out.push(...verdict("advisory", "NOT PREPARED", `${lab.missing.length} thing${lab.missing.length === 1 ? "" : "s"} before a run; nothing was fetched or written.`, c))
137
+ out.push(...verdict("advisory", "NOT PREPARED", `${lab.missing.length} thing${lab.missing.length === 1 ? "" : "s"} before a run; ${lab.newest?.checked ? "only the release list was read" : "nothing was fetched or written"}.`, c))
138
+ } else if (lab.behind) {
139
+ out.push(...verdict("advisory", "PREPARED", `on Omarchy ${lab.behind.base}, which a run uses; Omarchy ${lab.behind.newest} is the newest, and \`omakit lab setup\` builds it. Only the release list was read.`, c))
89
140
  } else {
90
- out.push(...verdict("pass", "PREPARED", `verified ${release.name}, a ready base, the host can run it; nothing was fetched or written.`, c))
141
+ const on = lab.base.manifest?.release?.name
142
+ out.push(...verdict("pass", "PREPARED", `${on ? `Omarchy ${on}, ` : ""}a ready base the host can run${newest.checked ? ", the newest release" : `; the newest release was not looked up (${newest.reason})`}. ${newest.checked ? "Only the release list was read." : "Nothing was fetched or written."}`, c))
91
143
  }
92
144
  return out.join("\n")
93
145
  }
@@ -106,7 +158,9 @@ export function renderSetupPlan(plan, { colour = colourEnabled(), env = process.
106
158
  return out.join("\n")
107
159
  }
108
160
  if (!plan.steps.length) {
109
- out.push(...verdict("pass", "PREPARED", "the verified ISO and a ready base are there; nothing to acquire.", c))
161
+ if (plan.stale) out.push(...verdict("advisory", "PREPARED", `the base on disk is Omarchy ${plan.stale.base}, left as it is: the newest release could not be looked up (${plan.stale.reason}). Run \`omakit lab setup\` again with the network.`, c))
162
+ else if (plan.ahead) out.push(...verdict("advisory", "PREPARED", `the base on disk is Omarchy ${plan.ahead.base}, newer than ${plan.ahead.release}, ${plan.ahead.from === "newest" ? "the newest release published whole now" : "the release the file named by --from is"}; it is kept, and nothing older is fetched or built over it.`, c))
163
+ else out.push(...verdict("pass", "PREPARED", `Omarchy ${plan.pin.release.name}, ${plan.newest?.checked ? "the newest release" : "the release the file named by --from is (the newest was not looked up)"}: the verified ISO and a ready base are there; nothing to acquire.`, c))
110
164
  return out.join("\n")
111
165
  }
112
166
  out.push(...section("what setup will do, once you say yes", c))
@@ -123,11 +177,13 @@ export function renderSetupResult(result, { colour = colourEnabled(), env = proc
123
177
  else if (step.kind === "import") out.push(`${mark("pass", c)}copied ${bytesBoth(step.bytes)} from ${withHomeAbbreviated(step.from, env)} in ${durationWords(step.milliseconds)}`)
124
178
  else if (step.kind === "sidecars") out.push(`${mark("pass", c)}fetched the checksum and signature sidecars into ${withHomeAbbreviated(step.to, env)}`)
125
179
  else if (step.kind === "verify") out.push(`${mark("pass", c)}verified ${withHomeAbbreviated(step.file, env)}`, ...wrap(step.reason, { indent: GUTTER }, c))
126
- else if (step.kind === "build") out.push(`${mark("pass", c)}built and promoted the base in ${durationWords(step.milliseconds)}: guest omarchy ${step.guest.version}, ${bytesBoth(step.allocatedBytes)} allocated, disk sha256 ${step.diskSha256}`)
180
+ else if (step.kind === "build") out.push(`${mark("pass", c)}built and promoted the Omarchy ${step.release} base in ${durationWords(step.milliseconds)}: guest omarchy ${step.guest.version}, ${bytesBoth(step.allocatedBytes)} allocated, disk sha256 ${step.diskSha256}`)
181
+ else if (step.kind === "superseded" && step.removed.length) out.push(`${mark("pass", c)}removed ${step.removed.length === 1 ? "the older download" : `${step.removed.length} older downloads`} (${step.removed.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")}), ${bytesBoth(step.removed.reduce((sum, entry) => sum + entry.bytes, 0))}`)
127
182
  else if (step.kind === "plugins") out.push(`${mark("pass", c)}${step.fetched.length} listed plugins in the lab cache: ${step.fetched.map((row) => `${row.id} ${row.state}`).join(", ")}`)
128
183
  }
129
184
  out.push("")
130
- out.push(...verdict("pass", "PREPARED", "the lab is ready; `omakit lab prove <suite>` boots it.", c))
185
+ const built = result.done.find((step) => step.kind === "build")
186
+ out.push(...verdict("pass", "PREPARED", `the lab is ready${built ? ` on Omarchy ${built.release}` : ""}; \`omakit lab prove <suite>\` boots it.`, c))
131
187
  return out.join("\n")
132
188
  }
133
189
 
@@ -136,12 +192,16 @@ export function renderRunIdentity(record, { colour = colourEnabled() } = {}) {
136
192
  const c = styler(colour)
137
193
  const out = []
138
194
  out.push(...field("run", record.runId, c))
139
- out.push(...field("release", record.pin.release, c))
195
+ out.push(...field("release", `Omarchy ${record.pin.release}`, c))
196
+ const newest = record.newest || { release: null, reason: "not looked up" }
197
+ out.push(...field("newest", record.behind
198
+ ? `Omarchy ${newest.release} is out and this base is ${record.pin.release}: the run uses the base there; \`omakit lab setup\` builds ${newest.release}`
199
+ : newest.release ? `this is the newest release (${newest.tag}, checked ${String(newest.checkedAt).slice(0, 10)})` : `not looked up: ${newest.reason}`, c))
140
200
  out.push(...field("guest", record.guest ? `omarchy ${record.guest.version || "unknown"} on ${record.guest.kernel || "?"}` : "not read", c))
141
201
  out.push(...field("iso sha256", record.pin.isoSha256, c, { wrapValue: false }))
142
202
  out.push(...field("base", `created ${record.base.createdAt}; ${bytesBoth(record.base.allocatedBytes)}; disk sha256 ${shortId(record.base.diskSha256)}`, c))
143
203
  out.push(...field("tested", record.testedSource || "unknown", c))
144
- out.push(...field("skew", record.skew === null ? "unknown" : record.skew ? `true: the guest is ${record.guest?.linked ? "linked to a checkout" : `omarchy ${record.guest?.version}`}, the pin expects ${record.pin.expectedGuestVersion}` : `false: the installed package is the pinned ${record.pin.expectedGuestVersion}`, c))
204
+ out.push(...field("skew", record.skew === null ? "unknown" : record.skew ? `true: the guest is ${record.guest?.linked ? "linked to a checkout" : `omarchy ${record.guest?.version}`}, the base was built with ${record.pin.expectedGuestVersion}` : `false: the installed package is the base's ${record.pin.expectedGuestVersion}`, c))
145
205
  return out.join("\n")
146
206
  }
147
207
 
@@ -154,7 +214,7 @@ export function renderRunResult(record, { colour = colourEnabled(), env = proces
154
214
  if (record.document) out.push(...field("document", withHomeAbbreviated(record.document, env), c))
155
215
  out.push(...field("record", withHomeAbbreviated(`${record.runDir}/run.json`, env), c))
156
216
  out.push("")
157
- if (record.ok) out.push(...verdict("pass", "PROVED", `${record.suite}: ${record.assertion?.reason || "ok"}; guest omarchy ${record.guest?.version}, skew ${record.skew}.`, c))
217
+ if (record.ok) out.push(...verdict("pass", "PROVED", `${record.suite}: ${record.assertion?.reason || "ok"}; guest omarchy ${record.guest?.version}, skew ${record.skew}${record.behind ? `; Omarchy ${record.newest.release} is out, and \`omakit lab setup\` builds it` : ""}.`, c))
158
218
  else out.push(...verdict("fail", "NOT PROVED", `${record.suite}: ${record.assertion?.reason || record.error || `the suite exited ${record.suiteStatus}`}.`, c))
159
219
  return out.join("\n")
160
220
  }
package/tools/lab/run.mjs CHANGED
@@ -40,10 +40,10 @@ import { spawn, spawnSync } from "node:child_process"
40
40
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"
41
41
  import { createServer } from "node:net"
42
42
  import { join, resolve } from "node:path"
43
- import { bytesBoth, labPin } from "./pin.mjs"
43
+ import { bytesBoth, compareVersions, guestIsRelease, labPin, withRelease } from "./pin.mjs"
44
44
  import { allocatedBytes, inLab, labDir, labLayout, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
45
45
  import { freeBytesAt, guestCpus, probeRunHost } from "./host.mjs"
46
- import { BASE_FILES, inspectBase, inspectLock } from "./inspect.mjs"
46
+ import { BASE_FILES, baseRelease, inspectBase, inspectLock } from "./inspect.mjs"
47
47
  import { press, qemuArgs, qmpExecute, startQemu, typeText } from "./qemu.mjs"
48
48
  import { clearStartupNotifications, establishSession, guestIdentity, SESSION_PREAMBLE, sleep, sshGuest, waitForSsh } from "./guest.mjs"
49
49
  import { SUITES, suitePreflight } from "./suites.mjs"
@@ -209,13 +209,16 @@ export async function withGuest({ baseDir, stagingName, logFile, layout, pin, on
209
209
 
210
210
  /**
211
211
  * Everything a run needs before a guest boots, or a LabError naming what
212
- * is missing. Reads only.
212
+ * is missing. Reads only. The base is held to its own release: a run uses
213
+ * the base that is there, and whether a newer release is out is the run
214
+ * record's `newest`, said beside the result, never a reason to refuse.
213
215
  */
214
216
  export function preflightRun({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {} }) {
215
217
  const suite = SUITES[suiteName]
216
218
  if (!suite) throw new LabError("usage", `no suite named ${JSON.stringify(suiteName)}; the suites are ${Object.keys(SUITES).join(", ")}`, { remedy: `omakit lab prove <${Object.keys(SUITES).join("|")}>` })
217
219
  const layout = labLayout(env)
218
220
  const missing = []
221
+ pin = withRelease(pin, baseRelease(layout, pin))
219
222
  const base = inspectBase(layout, pin)
220
223
  if (base.state !== "ready") missing.push({ what: `a ready base (${base.state})`, cost: base.reason, command: "omakit lab setup" })
221
224
  for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
@@ -223,7 +226,7 @@ export function preflightRun({ suiteName, env = process.env, pin = labPin(), rep
223
226
  const free = freeBytesAt(layout.cache)
224
227
  if (free.bytes < pin.measured.overlayAfterRunBytes) missing.push({ what: "disk for one overlay", cost: `${bytesBoth(free.bytes)} free at ${free.path}; one run's overlay measured ${bytesBoth(pin.measured.overlayAfterRunBytes)} (M14)`, command: "omakit lab prune" })
225
228
  if (missing.length) throw new LabError("lab-not-ready", `\`omakit lab prove ${suiteName}\` cannot start: ${missing.length} thing${missing.length === 1 ? " is" : "s are"} missing`, { missing, remedy: missing[0].command })
226
- return { suite, layout, base, free }
229
+ return { suite, layout, base, free, pin }
227
230
  }
228
231
 
229
232
  /**
@@ -232,8 +235,21 @@ export function preflightRun({ suiteName, env = process.env, pin = labPin(), rep
232
235
  * Returns the run record; the suite's document is in the run directory,
233
236
  * with its provenance.
234
237
  */
235
- export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal }) {
236
- const { suite, layout, base } = preflightRun({ suiteName, env, pin, repoRoot, options })
238
+ export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal, checkNewest = null }) {
239
+ const preflight = preflightRun({ suiteName, env, pin, repoRoot, options })
240
+ const { suite, layout, base } = preflight
241
+ pin = preflight.pin
242
+ // Whether a newer release is out, asked once the run can start, so a
243
+ // run that cannot start reads nothing from the network. A failed check
244
+ // is recorded as not checked; it never stops a run.
245
+ let newest = { checked: false, code: "not-asked", reason: "the newest release was not looked up" }
246
+ if (checkNewest) {
247
+ onPhase("looking for the newest Omarchy release")
248
+ newest = await checkNewest()
249
+ // An interrupt during the lookup is an interrupt: nothing is booted.
250
+ if (signal?.aborted || newest.code === "interrupted") throw new LabError("interrupted", "interrupted while looking for the newest release; nothing was booted", { remedy: `omakit lab prove ${suite.name}, when you are ready` })
251
+ }
252
+ const behind = newest.checked ? compareVersions(pin.release.name, newest.release.name) < 0 || (pin.release.name === newest.release.name && pin.release.sha256 !== newest.release.sha256) : null
237
253
  const runId = `${stampNow()}-${suite.name}`
238
254
  const runDir = labDir(layout.state, "runs", runId)
239
255
  const startedAt = new Date()
@@ -242,7 +258,9 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
242
258
  runId,
243
259
  suite: suite.name,
244
260
  startedAt: startedAt.toISOString(),
245
- pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: pin.release.expectedGuestVersion },
261
+ pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: base.manifest.guest.version },
262
+ newest: newest.checked ? { release: newest.release.name, isoSha256: newest.release.sha256, tag: newest.tag, publishedAt: newest.publishedAt, checkedAt: newest.checkedAt } : { release: null, code: newest.code, reason: newest.reason },
263
+ behind,
246
264
  base: { dir: base.dir, createdAt: base.manifest.createdAt, allocatedBytes: base.allocatedBytes, diskSha256: base.manifest.disk.sha256, varsSha256: base.manifest.vars.sha256, origin: base.manifest.build?.origin || null },
247
265
  guest: null,
248
266
  skew: null,
@@ -266,7 +284,7 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
266
284
  booted = await withGuest({ baseDir: base.dir, stagingName: `run-${runId}`, logFile: join(runDir, "qemu.log"), layout, pin, onPhase, signal }, async ({ guest, paths, identity }) => {
267
285
  record.guest = identity
268
286
  record.host.sshPort = guest.port
269
- record.skew = identity.linked || identity.version !== pin.release.expectedGuestVersion
287
+ record.skew = identity.linked || !guestIsRelease(identity.version, pin.release)
270
288
  record.testedSource = identity.testedSource
271
289
  save()
272
290
  onLine({ state: "info", text: `guest omarchy ${identity.version || "unknown"} on ${identity.kernel || "?"}; tested source ${identity.testedSource}; skew ${record.skew}`, record })
@@ -286,7 +304,7 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
286
304
  // files already carried by hand (`where`), plus the identity a
287
305
  // reader can check: run id, guest version, pin, skew.
288
306
  document.where = `the ${record.skew ? "linked" : "stock"} Omarchy ${identity.version || "?"} guest of omakit lab, run ${runId}`
289
- document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
307
+ document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, newest: record.newest, behind: record.behind, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
290
308
  writeJson(layout.state, `runs/${runId}/${suite.document}`, document)
291
309
  }
292
310
  })