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.
- package/package.json +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/guest.mjs +1 -1
- package/tools/lab/host.mjs +2 -2
- package/tools/lab/inspect.mjs +170 -31
- package/tools/lab/paths.mjs +1 -1
- package/tools/lab/pin.json +10 -14
- package/tools/lab/pin.mjs +78 -13
- package/tools/lab/prune.mjs +10 -7
- package/tools/lab/release.mjs +178 -0
- package/tools/lab/report.mjs +78 -18
- package/tools/lab/run.mjs +27 -9
- package/tools/lab/setup.mjs +301 -68
- package/tools/lab/verify.mjs +149 -20
- package/tools/marketplace/README.md +11 -10
- package/tools/marketplace/cli.mjs +36 -8
- package/tools/marketplace/doctor.mjs +128 -23
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/pin.mjs +79 -14
- package/tools/marketplace/preflight.mjs +2 -2
- package/tools/marketplace/registry.mjs +31 -49
- package/tools/marketplace/review-cost.mjs +2 -3
- package/tools/marketplace/submit.mjs +2 -2
- package/tools/marketplace/usage.mjs +19 -16
- package/tools/marketplace/watch.mjs +2 -3
package/package.json
CHANGED
package/tools/blocks/commit.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"recordedAt": "2026-
|
|
2
|
+
"commit": "517b87d52ba42465a12ab5722e979ed6f8d27805",
|
|
3
|
+
"recordedAt": "2026-10-01T02:52:49.108Z",
|
|
4
4
|
"how": "Written by `node tools/blocks/record-commit.mjs` in the release workflow before `npm pack`, so a packaged omakit, which has no Git checkout, still names the commit its block files come from. In a checkout this file is null and `git rev-parse HEAD` is the source; a package with null here was packed without the release step, and `omakit add` refuses to stamp a header it cannot name."
|
|
5
5
|
}
|
package/tools/lab/guest.mjs
CHANGED
|
@@ -134,7 +134,7 @@ export async function clearStartupNotifications(guest, { onPhase = () => {} } =
|
|
|
134
134
|
* the package or from a linked checkout (`OMARCHY_PATH` in
|
|
135
135
|
* /etc/omarchy.conf; a dev-linked guest names `.local/share/omarchy`).
|
|
136
136
|
* This is the line the old gates never wrote (docs/history/2026-09-18-lab-
|
|
137
|
-
* inventory.md P8), and
|
|
137
|
+
* inventory.md P8), and docs/LAB.md's run identity requires.
|
|
138
138
|
*/
|
|
139
139
|
export function guestIdentity(guest) {
|
|
140
140
|
const read = (command) => sshGuest(guest, command).stdout.trim()
|
package/tools/lab/host.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What the host has and what it lacks, each with its measured reason.
|
|
2
2
|
//
|
|
3
3
|
// Read-only: every probe is a stat, a read of /proc, or a `--version`.
|
|
4
|
-
// Nothing here installs anything (
|
|
4
|
+
// Nothing here installs anything (docs/LAB.md: lab-only host
|
|
5
5
|
// capabilities are preflighted and reported, never installed). Measured
|
|
6
6
|
// before this (docs/history/2026-09-18-lab-inventory.md P10, P20): the
|
|
7
7
|
// upstream harness ran `omarchy-pkg-add` for six packages at the top of
|
|
@@ -168,7 +168,7 @@ export function probeRunHost({ pin = labPin(), run, env = process.env } = {}) {
|
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* The vCPU count a run gives the guest: every logical CPU, which is what
|
|
171
|
-
* the toolchain's `-smp $(nproc)` gave the reference build (32,
|
|
171
|
+
* the toolchain's `-smp $(nproc)` gave the reference build (32, M14). A
|
|
172
172
|
* smaller number would be a guess about what a suite needs, and no run
|
|
173
173
|
* has measured one.
|
|
174
174
|
*/
|
package/tools/lab/inspect.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// `omakit lab inspect`, and the lab lines of `omakit doctor`:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// `omakit lab inspect`, and the lab lines of `omakit doctor`: the newest
|
|
2
|
+
// release as the caller found it, what is on disk, whether it was
|
|
3
|
+
// verified, whether the base is behind, what the host is missing, and
|
|
4
|
+
// what each missing thing costs. Read-only: no directory is created, no
|
|
5
|
+
// file is written, nothing is fetched here; the one read of the release
|
|
6
|
+
// list is the caller's (release.mjs), handed in as `newest`. `--verify`
|
|
7
|
+
// re-hashes the ISO and re-checks the signature now instead of reporting
|
|
8
|
+
// the record.
|
|
6
9
|
|
|
7
|
-
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
8
11
|
import { join } from "node:path"
|
|
9
|
-
import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
|
|
12
|
+
import { LAB_DIR, bytesBoth, compareVersions, durationWords, guestIsRelease, labPin, releaseOf, VERSION } from "./pin.mjs"
|
|
10
13
|
import { allocatedBytes, labLayout, readJson } from "./paths.mjs"
|
|
11
14
|
import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
|
|
12
15
|
import { judgeRelease, sha256File } from "./verify.mjs"
|
|
@@ -26,13 +29,54 @@ export function downloadDir(layout, pin = labPin()) {
|
|
|
26
29
|
return join(layout.downloads, pin.release.sha256)
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The release the base on disk was built from, in the lab's release
|
|
34
|
+
* shape, from its manifest; null when there is no readable manifest. A
|
|
35
|
+
* run uses the base it has, so this is what a run holds the base to.
|
|
36
|
+
*/
|
|
37
|
+
export function baseRelease(layout, pin = labPin()) {
|
|
38
|
+
const manifest = readJson(join(layout.base, BASE_FILES.manifest))
|
|
39
|
+
const release = manifest?.release
|
|
40
|
+
if (!release || !VERSION.test(String(release.name)) || !/^[0-9a-f]{64}$/.test(String(release.sha256)) || !Number.isSafeInteger(release.bytes)) return null
|
|
41
|
+
return releaseOf(pin, { name: release.name, bytes: release.bytes, sha256: release.sha256 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one download directory holds, from its own files: the verification
|
|
46
|
+
* record names its release (a record written before 0.6.9 names only the
|
|
47
|
+
* digest, and then the ISO is the one `omarchy-<version>.iso` there).
|
|
48
|
+
* `{ name, fileName, record, verified }`, each null or false when absent.
|
|
49
|
+
*/
|
|
50
|
+
export function downloadEntry(dir, digest) {
|
|
51
|
+
const record = readJson(join(dir, "verified.json"))
|
|
52
|
+
let fileName = record?.fileName || null
|
|
53
|
+
let partial = null
|
|
54
|
+
if (!fileName && existsSync(dir)) {
|
|
55
|
+
try {
|
|
56
|
+
const names = readdirSync(dir)
|
|
57
|
+
fileName = names.find((name) => /^omarchy-\d+\.\d+\.\d+\.iso$/.test(name)) || null
|
|
58
|
+
partial = names.find((name) => /^omarchy-\d+\.\d+\.\d+\.iso\.part$/.test(name)) || null
|
|
59
|
+
} catch {
|
|
60
|
+
fileName = null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// A partial download names its release by its file name too, so a newer
|
|
64
|
+
// one is never mistaken for an older one.
|
|
65
|
+
const name = record?.name || (fileName || partial || "").match(/^omarchy-(\d+\.\d+\.\d+)\.iso/)?.[1] || null
|
|
66
|
+
const verified = Boolean(record) && record.sha256 === digest && Boolean(fileName) && existsSync(join(dir, fileName))
|
|
67
|
+
return { name, fileName, record, verified }
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
/**
|
|
30
71
|
* The ISO on disk against its verification record. The record is what
|
|
31
72
|
* `setup` wrote after the digest and the signature passed; the file is
|
|
32
73
|
* held to the record by size and mtime, which is what can be read in a
|
|
33
|
-
* millisecond, and `--verify` is the five-second re-hash.
|
|
74
|
+
* millisecond, and `--verify` is the five-second re-hash. With no release
|
|
75
|
+
* to look for (the newest was not found and there is no base), there is
|
|
76
|
+
* nothing to hold a file to, and the reason says so.
|
|
34
77
|
*/
|
|
35
78
|
export function inspectDownload(layout, pin = labPin(), { verify = false, stagingRoot = layout.staging, onProgress } = {}) {
|
|
79
|
+
if (!pin.release) return { dir: null, iso: null, present: false, bytes: null, partial: null, record: null, verified: false, reason: "no release to look for: the newest was not found and there is no base to name one", sidecars: { checksum: false, signature: false } }
|
|
36
80
|
const dir = downloadDir(layout, pin)
|
|
37
81
|
const iso = join(dir, pin.release.fileName)
|
|
38
82
|
const out = { dir, iso, present: false, bytes: null, partial: null, record: readJson(join(dir, "verified.json")), verified: false, reason: null, sidecars: { checksum: existsSync(`${iso}.sha256`), signature: existsSync(`${iso}.sig`) } }
|
|
@@ -65,10 +109,24 @@ export function inspectDownload(layout, pin = labPin(), { verify = false, stagin
|
|
|
65
109
|
}
|
|
66
110
|
|
|
67
111
|
/**
|
|
68
|
-
* The base
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
112
|
+
* The base, against `pin.release` (the newest release, or the base's own
|
|
113
|
+
* when a run holds it to itself):
|
|
114
|
+
* - `missing`: no base directory.
|
|
115
|
+
* - `invalid`: files without a complete manifest, or a disk whose size is
|
|
116
|
+
* not the recorded one; never booted.
|
|
117
|
+
* - `ready`: built from exactly this release (name and ISO digest), its
|
|
118
|
+
* guest the release's own package.
|
|
119
|
+
* - `outdated`: a good base of an older release, or of this release as it
|
|
120
|
+
* was published before Omarchy replaced the ISO; a run still uses it,
|
|
121
|
+
* and `setup` builds the newest and replaces it once that verifies.
|
|
122
|
+
* - `ahead`: a good base of a release newer than this one. Kept and used:
|
|
123
|
+
* the release search passes a newer tag over on a 404, so a moment when
|
|
124
|
+
* its checksum is being republished, or an offline `--from` of an older
|
|
125
|
+
* file, would otherwise take a good base back a release and delete the
|
|
126
|
+
* newer ISO. The lab never goes back on its own.
|
|
127
|
+
* - `mismatch`: a base whose guest is not its release's package.
|
|
128
|
+
* Never booted here. `allocatedBytes` is `du -B1` over the directory, the
|
|
129
|
+
* figure prune will recover.
|
|
72
130
|
*/
|
|
73
131
|
export function inspectBase(layout, pin = labPin()) {
|
|
74
132
|
const dir = layout.base
|
|
@@ -77,7 +135,8 @@ export function inspectBase(layout, pin = labPin()) {
|
|
|
77
135
|
out.allocatedBytes = allocatedBytes(dir)
|
|
78
136
|
const manifest = readJson(join(dir, BASE_FILES.manifest))
|
|
79
137
|
const diskThere = existsSync(out.disk)
|
|
80
|
-
|
|
138
|
+
const named = manifest?.release && VERSION.test(String(manifest.release.name)) && /^[0-9a-f]{64}$/.test(String(manifest.release.sha256)) && Number.isSafeInteger(manifest.release.bytes)
|
|
139
|
+
if (!manifest || manifest.state !== "ready" || !named || !manifest.guest || !manifest.disk) {
|
|
81
140
|
out.state = "invalid"
|
|
82
141
|
out.reason = diskThere ? `${BASE_FILES.disk} is there (${bytesBoth(out.allocatedBytes)}) but ${BASE_FILES.manifest} is ${manifest ? "incomplete" : "missing"}; it will not be booted` : `a base directory with no disk and ${manifest ? "an incomplete" : "no"} manifest; \`omakit lab prune\` removes it`
|
|
83
142
|
return out
|
|
@@ -101,13 +160,37 @@ export function inspectBase(layout, pin = labPin()) {
|
|
|
101
160
|
return out
|
|
102
161
|
}
|
|
103
162
|
}
|
|
104
|
-
|
|
163
|
+
const built = { name: manifest.release.name }
|
|
164
|
+
const described = `Omarchy ${manifest.release.name}, guest omarchy ${manifest.guest.version}; ${bytesBoth(out.allocatedBytes)}; created ${manifest.createdAt}`
|
|
165
|
+
if (!guestIsRelease(manifest.guest.version, built)) {
|
|
105
166
|
out.state = "mismatch"
|
|
106
|
-
out.reason = `
|
|
167
|
+
out.reason = `the base is Omarchy ${manifest.release.name} and its guest runs omarchy ${manifest.guest.version}, another release's package; \`omakit lab setup\` replaces it`
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
const release = pin.release
|
|
171
|
+
if (!release) {
|
|
172
|
+
out.state = "ready"
|
|
173
|
+
out.reason = described
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
const order = compareVersions(manifest.release.name, release.name)
|
|
177
|
+
if (order === 0 && manifest.release.sha256 === release.sha256) {
|
|
178
|
+
out.state = "ready"
|
|
179
|
+
out.reason = described
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
if (order < 0) {
|
|
183
|
+
out.state = "outdated"
|
|
184
|
+
out.reason = `Omarchy ${manifest.release.name} (guest omarchy ${manifest.guest.version}); Omarchy ${release.name} is the newest release. A run still uses this base; \`omakit lab setup\` builds ${release.name} and replaces it once the new one verifies`
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
if (order === 0) {
|
|
188
|
+
out.state = "outdated"
|
|
189
|
+
out.reason = `Omarchy ${manifest.release.name} as first published (iso ${manifest.release.sha256}); Omarchy has since republished it (iso ${release.sha256}). A run still uses this base; \`omakit lab setup\` rebuilds it from the republished ISO`
|
|
107
190
|
return out
|
|
108
191
|
}
|
|
109
|
-
out.state = "
|
|
110
|
-
out.reason = `Omarchy ${manifest.release.name}
|
|
192
|
+
out.state = "ahead"
|
|
193
|
+
out.reason = `Omarchy ${manifest.release.name} (guest omarchy ${manifest.guest.version}), newer than ${release.name}, the release this was held to; kept and used, never taken back a release (if Omarchy withdrew ${manifest.release.name}, \`omakit lab prune\` removes it and \`omakit lab setup\` builds ${release.name})`
|
|
111
194
|
return out
|
|
112
195
|
}
|
|
113
196
|
|
|
@@ -156,7 +239,45 @@ export function toolchainCommand(pin = labPin(), dir = join(labLayout().cache, "
|
|
|
156
239
|
return `git clone ${pin.toolchain.repository} ${dir} && git -C ${dir} checkout --detach ${pin.toolchain.commit} && git -C ${dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${dir}`
|
|
157
240
|
}
|
|
158
241
|
|
|
159
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* QEMU processes a build under `dir` left running: the toolchain starts
|
|
244
|
+
* its guest with -daemonize and a pidfile under the staging directory, so
|
|
245
|
+
* a setup interrupted mid-build (measured on 2026-09-23: a 4.0.3 build
|
|
246
|
+
* interrupted at 09:49 left its QEMU running at a third of a core, and it
|
|
247
|
+
* was still writing the staged disk eighteen minutes later) leaves a guest
|
|
248
|
+
* no command owns, and its QMP socket is in /tmp, where the socket check
|
|
249
|
+
* below cannot see it. A pid counts only while /proc names it with this
|
|
250
|
+
* directory, so a reused pid is never taken for it. Reads only.
|
|
251
|
+
*/
|
|
252
|
+
export function buildGuests(dir) {
|
|
253
|
+
const found = []
|
|
254
|
+
const runsRoot = join(dir, "test-runs")
|
|
255
|
+
if (!existsSync(runsRoot)) return found
|
|
256
|
+
for (const release of readdirSync(runsRoot)) {
|
|
257
|
+
const runs = join(runsRoot, release, "runs")
|
|
258
|
+
if (!existsSync(runs)) continue
|
|
259
|
+
for (const run of readdirSync(runs)) {
|
|
260
|
+
const pidFile = join(runs, run, "qemu.pid")
|
|
261
|
+
let pid
|
|
262
|
+
try {
|
|
263
|
+
pid = Number(readFileSync(pidFile, "utf8").trim())
|
|
264
|
+
} catch {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) continue
|
|
268
|
+
let cmdline = ""
|
|
269
|
+
try {
|
|
270
|
+
cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8")
|
|
271
|
+
} catch {
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (cmdline.split("\0").some((arg) => arg.includes(`${dir}/`))) found.push({ pid, pidFile })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return found
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Staging directories, each with whether a QEMU still answers on its socket or runs from its pidfile (the cross-namespace liveness question). */
|
|
160
281
|
export async function inspectStaging(layout) {
|
|
161
282
|
if (!existsSync(layout.staging)) return []
|
|
162
283
|
const entries = []
|
|
@@ -170,8 +291,9 @@ export async function inspectStaging(layout) {
|
|
|
170
291
|
}
|
|
171
292
|
if (!st.isDirectory()) continue
|
|
172
293
|
const socket = join(dir, "qmp.sock")
|
|
173
|
-
const
|
|
174
|
-
|
|
294
|
+
const guests = buildGuests(dir)
|
|
295
|
+
const alive = (existsSync(socket) ? (await qmpAlive(socket)).alive : false) || guests.length > 0
|
|
296
|
+
entries.push({ name, dir, allocatedBytes: allocatedBytes(dir), alive, socket: existsSync(socket) ? socket : null, pids: guests.map((guest) => guest.pid) })
|
|
175
297
|
}
|
|
176
298
|
return entries
|
|
177
299
|
}
|
|
@@ -194,13 +316,19 @@ export async function inspectLock(layout) {
|
|
|
194
316
|
}
|
|
195
317
|
|
|
196
318
|
/**
|
|
197
|
-
* Everything `inspect` and `doctor` print, as one document. `
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
319
|
+
* Everything `inspect` and `doctor` print, as one document. `newest` is the
|
|
320
|
+
* caller's release check (release.mjs `checkNewestRelease`, or
|
|
321
|
+
* `{ checked: false, code: "offline" }` when it was skipped); the lab is
|
|
322
|
+
* held to that release when it was found, and to the base's own release
|
|
323
|
+
* when it was not, which the document says. `host` is the run probes plus
|
|
324
|
+
* what acquisition and a build need; `missing` is the list of what stands
|
|
325
|
+
* between this host and a run, each with its cost and the one command. An
|
|
326
|
+
* `outdated` base is not missing: a run uses it, and `behind` says so.
|
|
201
327
|
*/
|
|
202
|
-
export async function inspectLab({ env = process.env, pin = labPin(), verify = false, onProgress, run } = {}) {
|
|
328
|
+
export async function inspectLab({ env = process.env, pin = labPin(), newest = { checked: false, code: "not-asked", reason: "the newest release was not looked up" }, verify = false, onProgress, run } = {}) {
|
|
203
329
|
const layout = labLayout(env)
|
|
330
|
+
const reference = newest?.checked ? newest.release : baseRelease(layout, pin)
|
|
331
|
+
pin = { ...pin, release: reference }
|
|
204
332
|
const download = inspectDownload(layout, pin, { verify, onProgress })
|
|
205
333
|
const base = inspectBase(layout, pin)
|
|
206
334
|
const toolchain = inspectToolchain(layout, pin)
|
|
@@ -218,23 +346,34 @@ export async function inspectLab({ env = process.env, pin = labPin(), verify = f
|
|
|
218
346
|
runsBytes: existsSync(layout.runs) ? allocatedBytes(layout.runs) : 0,
|
|
219
347
|
}
|
|
220
348
|
const missing = []
|
|
349
|
+
const usable = ["ready", "outdated", "ahead"].includes(base.state)
|
|
221
350
|
for (const line of host.run) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
222
|
-
|
|
351
|
+
// The verified ISO matters for building a base; with a usable base
|
|
352
|
+
// there, a run needs none, and a newer release's ISO is part of what
|
|
353
|
+
// `behind` names rather than something a run is missing.
|
|
354
|
+
if (!download.verified && !usable) {
|
|
223
355
|
missing.push({
|
|
224
356
|
what: "the verified ISO",
|
|
225
|
-
cost:
|
|
357
|
+
cost: !pin.release
|
|
358
|
+
? `the newest Omarchy release, which could not be looked up (${newest?.reason || "not asked"}); \`omakit lab setup\` looks it up and downloads it`
|
|
359
|
+
: download.present
|
|
360
|
+
? `verification of the ${bytesBoth(download.bytes)} on disk (about ${durationWords(5215 + 9430)} on the reference host: the hash and the signature check)`
|
|
361
|
+
: `a download of ${bytesBoth(pin.release.bytes)} from ${pin.release.isoUrl}, verified against its published sha256 and the Omarchy signature, into ${download.dir}`,
|
|
226
362
|
command: "omakit lab setup",
|
|
227
363
|
})
|
|
228
364
|
}
|
|
229
|
-
if (toolchain.state !== "ready" &&
|
|
365
|
+
if (toolchain.state !== "ready" && !usable) {
|
|
230
366
|
missing.push({ what: "the toolchain", cost: `a checkout of omarchy-iso at ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch} applied; it drives the installer once, to build the base`, command: toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
|
|
231
367
|
}
|
|
232
|
-
if (
|
|
368
|
+
if (!usable) {
|
|
233
369
|
missing.push({
|
|
234
370
|
what: base.state === "missing" ? "a prepared base" : `a usable base (the one there is ${base.state})`,
|
|
235
|
-
cost: `${bytesBoth(pin.measured.baseDirectoryBytes)} on disk, ${durationWords(pin.measured.buildMilliseconds)} to build on the reference host (M14), plus one verification boot`,
|
|
371
|
+
cost: `${bytesBoth(pin.measured.baseDirectoryBytes)} on disk, ${durationWords(pin.measured.buildMilliseconds)} to build on the reference host with Omarchy ${pin.measured.release} (M14), plus one verification boot`,
|
|
236
372
|
command: "omakit lab setup",
|
|
237
373
|
})
|
|
238
374
|
}
|
|
239
|
-
|
|
375
|
+
const behind = base.state === "outdated"
|
|
376
|
+
? { base: base.manifest.release.name, newest: pin.release.name, command: "omakit lab setup", cost: `${download.verified ? "0 B to download" : `a download of ${bytesBoth(pin.release.bytes)}`}, a build of about ${durationWords(pin.measured.buildMilliseconds)} (Omarchy ${pin.measured.release} on the reference host, M14); the base there stays until the new one verifies` }
|
|
377
|
+
: null
|
|
378
|
+
return { pin: { release: pin.release, releases: pin.releases, toolchain: pin.toolchain, guest: pin.guest, measured: pin.measured }, newest, layout, download, base, behind, toolchain, staging, lock, host, free, runs, totals, missing }
|
|
240
379
|
}
|
package/tools/lab/paths.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function labStateDir(env = process.env) {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* The layout under the cache root. One base, not one per release
|
|
32
|
-
* (
|
|
32
|
+
* (docs/LAB.md): setup replaces it when a newer release is built.
|
|
33
33
|
*/
|
|
34
34
|
export function labLayout(env = process.env) {
|
|
35
35
|
const cache = labCacheDir(env)
|
package/tools/lab/pin.json
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schema":
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"checksumUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sha256",
|
|
9
|
-
"signatureUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sig",
|
|
10
|
-
"fileName": "omarchy-4.0.3.iso",
|
|
11
|
-
"bytes": 6260654080,
|
|
12
|
-
"sha256": "03d60bc74306dca51f96e1a84b690871d8d606826b260edd0208962da8507d14",
|
|
2
|
+
"schema": 2,
|
|
3
|
+
"releases": {
|
|
4
|
+
"list": "https://api.github.com/repos/omacom/omarchy/releases?per_page=30",
|
|
5
|
+
"iso": "https://iso.omarchy.org/omarchy-{version}.iso",
|
|
6
|
+
"floor": "4.0.3",
|
|
7
|
+
"candidates": 3,
|
|
13
8
|
"signingFingerprint": "40DFB630FF42BCFFB047046CF0134EE680CAC571",
|
|
14
9
|
"signingKey": "omarchy.gpg",
|
|
15
|
-
"signingKeySha256": "15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de"
|
|
16
|
-
"expectedGuestVersion": "4.0.3-1"
|
|
10
|
+
"signingKeySha256": "15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de"
|
|
17
11
|
},
|
|
18
12
|
"toolchain": {
|
|
19
13
|
"repository": "https://github.com/omacom-io/omarchy-iso",
|
|
@@ -31,12 +25,14 @@
|
|
|
31
25
|
},
|
|
32
26
|
"measured": {
|
|
33
27
|
"on": "2026-09-18",
|
|
28
|
+
"release": "4.0.3",
|
|
29
|
+
"isoBytes": 6260654080,
|
|
34
30
|
"buildMilliseconds": 357800,
|
|
35
31
|
"baseAllocatedBytes": 6181490688,
|
|
36
32
|
"baseDirectoryBytes": 6182264832,
|
|
37
33
|
"preparedLabBytes": 12442931200,
|
|
38
34
|
"overlayAfterRunBytes": 610734080,
|
|
39
35
|
"pluginsBytes": 6836224,
|
|
40
|
-
"source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host
|
|
36
|
+
"source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host with Omarchy 4.0.3, re-measuring the lab plan's observations of 2026-09-10 to 2026-09-13 (M4 to M7, in git history at 9c667f9)"
|
|
41
37
|
}
|
|
42
38
|
}
|
package/tools/lab/pin.mjs
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// What the lab holds fixed: where Omarchy's releases are listed and where
|
|
2
|
+
// their ISOs are published, the signer every release must carry, the
|
|
3
|
+
// oldest release it will take, the toolchain that builds a base, and the
|
|
4
|
+
// costs measured on the reference host.
|
|
3
5
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Which release the lab prepares is not in this file: `release.mjs` finds
|
|
7
|
+
// the newest one that is published whole (ISO, checksum, signature) each
|
|
8
|
+
// time `setup` runs, so the lab never stays on an old release unnoticed.
|
|
9
|
+
// The trust is the signature, not the list: a file is the release only
|
|
10
|
+
// when its detached signature verifies against the packaged key at the
|
|
11
|
+
// pinned fingerprint (verify.mjs). The inventory's objection to "latest"
|
|
12
|
+
// (docs/history/2026-09-18-lab-inventory.md P13) was a checksum trusted
|
|
13
|
+
// from the same host as the file with no signature checked at all; that
|
|
14
|
+
// is what the pinned signer answers.
|
|
9
15
|
|
|
10
16
|
import { readFileSync } from "node:fs"
|
|
11
17
|
import { dirname, join } from "node:path"
|
|
@@ -13,23 +19,82 @@ import { fileURLToPath } from "node:url"
|
|
|
13
19
|
|
|
14
20
|
export const LAB_DIR = dirname(fileURLToPath(import.meta.url))
|
|
15
21
|
|
|
22
|
+
/** A release name the lab takes: three numbers, as Omarchy tags them without the v. */
|
|
23
|
+
export const VERSION = /^(\d+)\.(\d+)\.(\d+)$/
|
|
24
|
+
|
|
16
25
|
let cached = null
|
|
17
26
|
|
|
18
|
-
/** The pin, parsed once. `file` is injectable for tests that need
|
|
27
|
+
/** The pin, parsed once. `file` is injectable for tests that need their own. */
|
|
19
28
|
export function labPin(file = join(LAB_DIR, "pin.json")) {
|
|
20
29
|
if (file === join(LAB_DIR, "pin.json") && cached) return cached
|
|
21
30
|
const pin = JSON.parse(readFileSync(file, "utf8"))
|
|
22
|
-
for (const key of ["
|
|
31
|
+
for (const key of ["releases", "toolchain", "guest", "measured"]) {
|
|
23
32
|
if (!pin[key] || typeof pin[key] !== "object") throw new Error(`lab pin: no ${key} section in ${file}`)
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
if (!/^[
|
|
27
|
-
if (
|
|
28
|
-
if (/latest/i.test(
|
|
34
|
+
const { releases } = pin
|
|
35
|
+
if (!/^https:\/\/api\.github\.com\/repos\/[\w.-]+\/[\w.-]+\/releases(?:\?[\w=&]*)?$/.test(releases.list)) throw new Error("lab pin: the release list is not a GitHub releases URL")
|
|
36
|
+
if (!/^https:\/\/[\w.-]+\/[\w./-]*\{version\}[\w./-]*\.iso$/.test(releases.iso)) throw new Error("lab pin: the ISO URL is not an https template with {version}")
|
|
37
|
+
if (/latest/i.test(releases.iso)) throw new Error("lab pin: the ISO URL names latest; a release is found by its version")
|
|
38
|
+
if (!VERSION.test(releases.floor)) throw new Error("lab pin: the floor is not a release name")
|
|
39
|
+
if (!Number.isInteger(releases.candidates) || releases.candidates < 1) throw new Error("lab pin: candidates is not a positive integer")
|
|
40
|
+
if (!/^[0-9A-F]{40}$/.test(releases.signingFingerprint)) throw new Error("lab pin: the signing fingerprint is not 40 hex characters")
|
|
41
|
+
if (!/^[0-9a-f]{64}$/.test(releases.signingKeySha256)) throw new Error("lab pin: the signing key digest is not 64 hex characters")
|
|
29
42
|
if (file === join(LAB_DIR, "pin.json")) cached = pin
|
|
30
43
|
return pin
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
/** -1, 0 or 1, by the three numbers; a name that is not a release sorts below every one that is. */
|
|
47
|
+
export function compareVersions(a, b) {
|
|
48
|
+
const left = String(a).match(VERSION)
|
|
49
|
+
const right = String(b).match(VERSION)
|
|
50
|
+
if (!left || !right) return left ? 1 : right ? -1 : 0
|
|
51
|
+
for (const index of [1, 2, 3]) {
|
|
52
|
+
const difference = Number(left[index]) - Number(right[index])
|
|
53
|
+
if (difference) return difference < 0 ? -1 : 1
|
|
54
|
+
}
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One release in the shape the lab reads everywhere: its name, where its
|
|
60
|
+
* ISO and the two sidecars are, its byte count and digest as published,
|
|
61
|
+
* and the pinned signer it must carry. `bytes` and `sha256` come from the
|
|
62
|
+
* host that publishes the release; the signature is what makes them
|
|
63
|
+
* trusted, and verify.mjs checks it before anything boots the file.
|
|
64
|
+
*/
|
|
65
|
+
export function releaseOf(pin, { name, bytes, sha256 }) {
|
|
66
|
+
if (!VERSION.test(String(name))) throw new Error(`lab: ${JSON.stringify(name)} is not a release name`)
|
|
67
|
+
const isoUrl = pin.releases.iso.replace("{version}", name)
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
fileName: isoUrl.split("/").at(-1),
|
|
71
|
+
isoUrl,
|
|
72
|
+
checksumUrl: `${isoUrl}.sha256`,
|
|
73
|
+
signatureUrl: `${isoUrl}.sig`,
|
|
74
|
+
bytes,
|
|
75
|
+
sha256,
|
|
76
|
+
signingFingerprint: pin.releases.signingFingerprint,
|
|
77
|
+
signingKey: pin.releases.signingKey,
|
|
78
|
+
signingKeySha256: pin.releases.signingKeySha256,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The pin with one release in it: what setup, inspect and a run read as `pin.release`. */
|
|
83
|
+
export function withRelease(pin, release) {
|
|
84
|
+
return { ...pin, release }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether an installed `omarchy` package version is this release's: the
|
|
89
|
+
* release name and one package revision (`4.0.3-1`). The ISO installs the
|
|
90
|
+
* package it carries, so any other version is a mislabelled release.
|
|
91
|
+
*/
|
|
92
|
+
export function guestIsRelease(version, release) {
|
|
93
|
+
if (!release || typeof version !== "string") return false
|
|
94
|
+
const match = version.match(/^(\d+\.\d+\.\d+)-(\d+)$/)
|
|
95
|
+
return Boolean(match) && match[1] === release.name
|
|
96
|
+
}
|
|
97
|
+
|
|
33
98
|
/**
|
|
34
99
|
* Bytes for a person, in both units, so a size cannot be made to look
|
|
35
100
|
* smaller by choosing one: `6,260,654,080 B (6.261 GB / 5.831 GiB)`.
|
package/tools/lab/prune.mjs
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, lstatSync, readdirSync } from "node:fs"
|
|
11
11
|
import { join } from "node:path"
|
|
12
|
-
import { bytesBoth, labPin } from "./pin.mjs"
|
|
12
|
+
import { bytesBoth, labPin, withRelease } from "./pin.mjs"
|
|
13
13
|
import { allocatedBytes, labLayout, removeFromLab } from "./paths.mjs"
|
|
14
|
-
import { inspectBase, inspectLock, inspectStaging } from "./inspect.mjs"
|
|
14
|
+
import { baseRelease, downloadEntry, inspectBase, inspectLock, inspectStaging } from "./inspect.mjs"
|
|
15
15
|
import { LabError } from "./run.mjs"
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -32,19 +32,22 @@ export async function planPrune({ env = process.env, pin = labPin(), keepIso = f
|
|
|
32
32
|
const active = staging.filter((entry) => entry.alive)
|
|
33
33
|
const blockers = []
|
|
34
34
|
if (lock.held && lock.alive) blockers.push(`run ${lock.record?.runId || "unknown"} holds the lab (pid ${lock.record?.pid || "?"}${lock.qemuAnswers ? ", its QEMU answers on QMP" : ""})`)
|
|
35
|
-
for (const entry of active) blockers.push(`a QEMU still answers on ${entry.socket}`)
|
|
35
|
+
for (const entry of active) blockers.push(entry.pids?.length ? `a build's QEMU still runs in ${entry.dir} (pid ${entry.pids.join(", ")}), left by a setup that ended; stop it with \`kill ${entry.pids.join(" ")}\` (the next setup that builds stops it too)` : `a QEMU still answers on ${entry.socket}`)
|
|
36
36
|
if (existsSync(layout.downloads)) {
|
|
37
37
|
for (const digest of readdirSync(layout.downloads)) {
|
|
38
|
+
// Each download directory says what it holds: its verification
|
|
39
|
+
// record names the release (a record written before 0.6.9 names only
|
|
40
|
+
// the digest, and the ISO is the one .iso file in the directory).
|
|
38
41
|
const dir = join(layout.downloads, digest)
|
|
39
|
-
const
|
|
42
|
+
const { name, fileName, verified } = lstatSync(dir).isDirectory() ? downloadEntry(dir, digest) : { name: null, fileName: null, verified: false }
|
|
40
43
|
if (verified && keepIso) {
|
|
41
|
-
add(layout.cache, `downloads/${digest}/${
|
|
44
|
+
add(layout.cache, `downloads/${digest}/${fileName}.part`, "a partial download beside the verified ISO")
|
|
42
45
|
continue
|
|
43
46
|
}
|
|
44
|
-
add(layout.cache, `downloads/${digest}`, verified ? `the verified ISO of ${
|
|
47
|
+
add(layout.cache, `downloads/${digest}`, verified ? `the verified ISO of ${name ? `Omarchy ${name}` : digest.slice(0, 12)} and its record` : `an unverified or partial download${name ? ` of Omarchy ${name}` : ` (${digest.slice(0, 12)})`}`)
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
|
-
const base = inspectBase(layout, pin)
|
|
50
|
+
const base = inspectBase(layout, withRelease(pin, baseRelease(layout, pin)))
|
|
48
51
|
if (base.state !== "missing") add(layout.cache, "base", `the ${base.state} base${base.manifest ? ` (Omarchy ${base.manifest.release.name}, guest ${base.manifest.guest.version})` : ""}`)
|
|
49
52
|
for (const entry of staging) if (!entry.alive) add(layout.cache, `staging/${entry.name}`, `staging left by ${entry.name}`)
|
|
50
53
|
if (existsSync(layout.plugins)) add(layout.cache, "plugins", "the listed plugins cached for the weigh evidence gate")
|