omakit 0.6.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Tested plumbing, the marketplace's own checks, and a disposable Omarchy to test in.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -164,4 +164,8 @@ that asked for each are in `docs/BLOCKS.md`; the ones that shape the call:
164
164
  `omakit inspect` lists an unmodified store block as one row and each
165
165
  `Store {}` site as a write under a directory the plugin controls at mode
166
166
  0600; a `FileView` write or a shell redirect that remains names a site
167
- that still keeps state on its own.
167
+ that still keeps state on its own. A `cp`, `mv`, `install` or `ln` to a
168
+ variable destination is not state Store keeps: the file goes wherever
169
+ the variable points (a hook, a wallpaper), and no block covers that yet;
170
+ the review's requirements for the class are in `docs/MEASUREMENTS.md`
171
+ (M11).
@@ -1,5 +1,5 @@
1
1
  {
2
- "commit": "5dd9db1969775b87b10f21445570557cf82e8073",
3
- "recordedAt": "2026-09-21T20:24:57.368Z",
2
+ "commit": "275ab1557edc5c8d152068e323659a5cc831ebbe",
3
+ "recordedAt": "2026-09-23T09:18:44.425Z",
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
  }
@@ -14,7 +14,7 @@ const ARGV_FORMS = new Set(["array", "string", "computed"])
14
14
  const DEADLINE_VIA = new Set(["timer-kill", "timeout-argv", "destruction", "block-run", null])
15
15
  const COLLECTORS = new Set(["StdioCollector", "SplitParser", "Run", "none", "unknown"])
16
16
  const BLOCK_STATES = new Set(["unmodified", "modified"])
17
- const CONTROLLED = new Set(["observed", "not-observed", "unknown"])
17
+ const CONTROLLED = new Set(["observed", "not-observed", "variable", "unknown"])
18
18
  const DECLARED_IN = new Set(["qml", "shell"])
19
19
  const FILE_KINDS = ["qml", "js", "shell", "python", "other"]
20
20
 
@@ -83,9 +83,10 @@ function write(row, at, problems) {
83
83
  if (!isString(row.path)) problems.push(`${at}.path is not a string`)
84
84
  if (!nullOr(isString)(row.canonicalPath)) problems.push(`${at}.canonicalPath is neither a string nor null`)
85
85
  if (!isString(row.via)) problems.push(`${at}.via is not a string`)
86
- if (!CONTROLLED.has(row.controlledDirectory)) problems.push(`${at}.controlledDirectory is not observed, not-observed or unknown`)
86
+ if (!CONTROLLED.has(row.controlledDirectory)) problems.push(`${at}.controlledDirectory is not observed, not-observed, variable or unknown`)
87
87
  if ((row.controlledDirectory === "observed") !== isString(row.controlledBy)) problems.push(`${at}.controlledBy names a directory exactly when controlledDirectory is observed`)
88
88
  if (row.canonicalPath === null && row.controlledDirectory !== "unknown") problems.push(`${at}.controlledDirectory is decided for a path that could not be read`)
89
+ if (row.controlledDirectory === "variable" && (!/^\$(?:[A-Za-z_]\w*|\d)\/?$/.test(row.canonicalPath ?? "") || /^\$(?:HOME|XDG_)/.test(row.canonicalPath))) problems.push(`${at}.controlledDirectory is variable for a path that is not one variable other than $HOME and the XDG names`)
89
90
  if (!isBool(row.temp)) problems.push(`${at}.temp is not a boolean`)
90
91
  if (!nullOr(isString)(row.mode)) problems.push(`${at}.mode is neither a string nor null`)
91
92
  if ((row.via === "block-store") !== (row.block === "store")) problems.push(`${at}.via block-store and block: "store" go together`)
@@ -51,6 +51,39 @@ function pathResolved(process) {
51
51
  return tool
52
52
  }
53
53
 
54
+ /**
55
+ * The verbs that put a file at a destination the caller names. A write by
56
+ * one of them whose destination is one variable (`cp "$src" "$dest"`) is
57
+ * the shape of both file-boundary blockers in one review thread,
58
+ * `install-wallpaper.sh` (`cp -f`, 2026-09-11) and `install-hook.sh`
59
+ * (`cp`, 2026-09-22), and the class cited neither copy: the path was
60
+ * `unknown`, with 52 of that tree's 54 writes. A link planted at the
61
+ * destination picks where the file lands for all four, measured on GNU
62
+ * coreutils 9.11: `cp` writes through a link to a file, and all four put
63
+ * the file inside a linked directory. A redirect or `tee` onto a variable
64
+ * is left out, measured over 15 plugin trees on 2026-09-23: 28 writes by
65
+ * these four verbs to a variable, against 61 redirects and tees, 24 of
66
+ * them `>>` appends to a log (docs/MEASUREMENTS.md, M11).
67
+ */
68
+ const PLACING = new Set(["cp", "mv", "install", "ln"])
69
+
70
+ /**
71
+ * The writes the file and state boundary class cites, by what it cites
72
+ * them for. Each test reads one row alone, so the report can ask it of the
73
+ * rows at one site and show the write the class cited there, not another
74
+ * write on the same line (`printf ... > "$f.tmp" && mv "$f.tmp" "$f"`).
75
+ */
76
+ export function boundaryWrites(writes) {
77
+ return {
78
+ outside: writes.filter((row) => row.controlledDirectory === "not-observed" && row.via !== "mktemp"),
79
+ placed: writes.filter((row) => row.controlledDirectory === "variable" && PLACING.has(row.via)),
80
+ unmoded: writes.filter((row) => row.via === "mkdir" && row.mode === null),
81
+ }
82
+ }
83
+
84
+ /** Words as a list a person reads, in the order given: "cp", "cp and ln", "mv, cp and ln". */
85
+ const listed = (words) => (words.length > 1 ? `${words.slice(0, -1).join(", ")} and ${words.at(-1)}` : words[0])
86
+
54
87
  const SECRET = /authorization|bearer|token=|api[_-]?key|password|secret=/i
55
88
  const PRIVILEGED = new Set(["sudo", "pkexec", "doas", "docker"])
56
89
 
@@ -210,17 +243,17 @@ export const PATTERNS = Object.freeze([
210
243
  {
211
244
  id: "file-and-state-boundary",
212
245
  label: "file and state boundary",
213
- notObserved: "no write outside a controlled directory",
246
+ notObserved: "no write outside a controlled directory or by cp, mv, install or ln to a variable, no mkdir without a mode",
214
247
  measurement: "M11",
215
248
  share: 0.15,
216
249
  sample: SAMPLE,
217
250
  precondition: ({ writes }) => {
218
- const outside = writes.filter((row) => row.controlledDirectory === "not-observed" && row.via !== "mktemp")
219
- const unmoded = writes.filter((row) => row.via === "mkdir" && row.mode === null)
251
+ const { outside, placed, unmoded } = boundaryWrites(writes)
220
252
  const parts = []
221
253
  if (outside.length) parts.push(`${plural(outside.length, "write")} outside a controlled directory (${sites(outside)})`)
254
+ if (placed.length) parts.push(`${plural(placed.length, "write")} by ${listed([...new Set(placed.map((row) => row.via))])} to a variable destination, not resolvable to a controlled directory (${sites(placed)})`)
222
255
  if (unmoded.length) parts.push(`${plural(unmoded.length, "mkdir")} with no mode (${sites(unmoded)})`)
223
- return { sites: [...outside, ...unmoded].map(site), observation: `observed ${parts.join("; ")}` }
256
+ return { sites: [...outside, ...placed, ...unmoded].map(site), observation: `observed ${parts.join("; ")}` }
224
257
  },
225
258
  },
226
259
  {
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { colourEnabled, field, GUTTER, INSPECT_VERDICT, mark, outputColumns, styler, verdict, wrap } from "../marketplace/style.mjs"
21
21
  import { withHomeAbbreviated } from "../marketplace/paths.mjs"
22
- import { PATTERNS, SIZE } from "./patterns.mjs"
22
+ import { boundaryWrites, PATTERNS, SIZE } from "./patterns.mjs"
23
23
 
24
24
  const NOTHING = "observed nothing of this kind"
25
25
 
@@ -110,7 +110,9 @@ function writeRow(write, c) {
110
110
  ? `observed (${write.controlledBy})`
111
111
  : write.controlledDirectory === "not-observed"
112
112
  ? `not observed${write.temp ? ` (${write.canonicalPath.split("/").slice(0, 2).join("/")} is shared)` : ""}`
113
- : "unknown (path not readable as a literal prefix)"
113
+ : write.controlledDirectory === "variable"
114
+ ? `not resolvable (${write.canonicalPath} is a variable)`
115
+ : "unknown (path not readable as a literal prefix)"
114
116
  const notes = [`under a directory the plugin controls: ${where}`]
115
117
  if (write.mode) notes.push(`mode ${write.mode}`)
116
118
  return row("info", site(write), what, c, [notes.join("; ")])
@@ -207,7 +209,7 @@ function commandLine(process) {
207
209
  }
208
210
 
209
211
  /**
210
- * The fact at a site, in one line: the write there for the file class, the
212
+ * The fact at a site, in one line: the write the file class cited there, the
211
213
  * host there for the egress class, otherwise the command, then the host,
212
214
  * then the write, whichever the site has.
213
215
  */
@@ -222,7 +224,9 @@ function factAt(document, at, patternId) {
222
224
  return row ? `${row.scheme}://${row.host}${row.tool ? ` via ${row.tool}` : ""}` : ""
223
225
  }
224
226
  const write = () => {
225
- const row = document.observed.writes.find(same)
227
+ // On a line with two writes, the one the file class cited, not the first.
228
+ const here = document.observed.writes.filter(same)
229
+ const row = (patternId === "file-and-state-boundary" ? Object.values(boundaryWrites(here)).flat()[0] : null) ?? here[0]
226
230
  return row ? `${row.via} ${row.canonicalPath ?? row.path}` : ""
227
231
  }
228
232
  const order = patternId === "file-and-state-boundary" ? [write, process, host] : patternId === "network-egress" ? [host, process, write] : [process, host, write]
@@ -3,7 +3,11 @@
3
3
  // or `touch` in shell; `writeFile` and its variants in JavaScript; `open()`
4
4
  // for writing in Python. Each row says whether the path's literal prefix,
5
5
  // after the `$HOME`, `~` and `XDG_*` idioms are expanded, falls under a
6
- // directory the plugin controls, and which mode the file shows for it.
6
+ // directory the plugin controls, and which mode the file shows for it. A
7
+ // path that is one variable and nothing else (`$dest`, `$2`, `$target/`)
8
+ // is said apart from any other path the classification cannot place: the
9
+ // write names no directory, and the extraction follows no assignment to
10
+ // find one.
7
11
 
8
12
  import { basename, blankComments, blocks, closingBracket, lineOf, propertyValue, blankShellExpressions, shellLogicalLines, shellPieces, shellWords, stringLiteral, withoutRedirections } from "./text.mjs"
9
13
 
@@ -11,6 +15,8 @@ import { basename, blankComments, blocks, closingBracket, lineOf, propertyValue,
11
15
  export const CONTROLLED = Object.freeze(["$XDG_STATE_HOME", "$XDG_CACHE_HOME", "$XDG_RUNTIME_DIR"])
12
16
  export const SHARED_TEMP = Object.freeze(["/tmp", "/var/tmp", "/dev/shm"])
13
17
  const DEVICES = new Set(["/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"])
18
+ /** A canonical path that is one variable expansion, a name or a positional parameter, with at most a trailing slash. */
19
+ const VARIABLE = /^\$(?:[A-Za-z_]\w*|\d)\/?$/
14
20
 
15
21
  /**
16
22
  * A path's literal prefix in canonical form: `~` and `$HOME` idioms,
@@ -45,8 +51,12 @@ export function canonicalPath(raw) {
45
51
  }
46
52
 
47
53
  /**
48
- * Whether a canonical path is under a directory the plugin controls.
49
- * @returns {{ controlledDirectory: "observed"|"not-observed"|"unknown", controlledBy: string|null, temp: boolean }}
54
+ * Whether a canonical path is under a directory the plugin controls:
55
+ * `variable` when the path is one variable other than `$HOME` and the XDG
56
+ * names, so the write names no directory; `unknown` for any other path it
57
+ * cannot place, an expression, a relative path or a path under a variable
58
+ * (`$dir/name`).
59
+ * @returns {{ controlledDirectory: "observed"|"not-observed"|"variable"|"unknown", controlledBy: string|null, temp: boolean }}
50
60
  */
51
61
  export function classifyPath(path, pluginId) {
52
62
  if (path === null || path === undefined) return { controlledDirectory: "unknown", controlledBy: null, temp: false }
@@ -59,6 +69,7 @@ export function classifyPath(path, pluginId) {
59
69
  if (path === prefix || path.startsWith(`${prefix}/`)) return { controlledDirectory: "not-observed", controlledBy: null, temp: true }
60
70
  }
61
71
  if (path.startsWith("/") || path.startsWith("$HOME") || path.startsWith("$XDG_")) return { controlledDirectory: "not-observed", controlledBy: null, temp: false }
72
+ if (VARIABLE.test(path)) return { controlledDirectory: "variable", controlledBy: null, temp: false }
62
73
  return { controlledDirectory: "unknown", controlledBy: null, temp: false }
63
74
  }
64
75
 
@@ -126,21 +137,31 @@ function pythonWrites(file, pluginId) {
126
137
  return rows
127
138
  }
128
139
 
129
- /** The words that are not options, and the value of `-m`/`--mode` when one is given. */
130
- function operands(all) {
140
+ /**
141
+ * The words that are not options, the value of `-m`/`--mode` when one is
142
+ * given, and, with `targets`, of `-t`/`--target-directory`: for `cp`, `mv`,
143
+ * `install` and `ln` that directory is the destination, and every operand
144
+ * a source.
145
+ */
146
+ function operands(all, { targets = false } = {}) {
131
147
  const words = withoutRedirections(all)
132
148
  const out = []
133
149
  let mode = null
150
+ let target = null
134
151
  for (let index = 1; index < words.length; index += 1) {
135
152
  const word = words[index]
136
153
  if (word === "-m" || word === "--mode") {
137
154
  mode = words[index + 1] ?? null
138
155
  index += 1
139
156
  } else if (word.startsWith("--mode=")) mode = word.slice("--mode=".length)
157
+ else if (targets && (word === "-t" || word === "--target-directory")) {
158
+ target = words[index + 1] ?? null
159
+ index += 1
160
+ } else if (targets && word.startsWith("--target-directory=")) target = word.slice("--target-directory=".length)
140
161
  else if (word.startsWith("-") && word !== "-") continue
141
162
  else out.push(word)
142
163
  }
143
- return { operands: out, mode }
164
+ return { operands: out, mode, target }
144
165
  }
145
166
 
146
167
  function shellWrites(file, pluginId) {
@@ -179,8 +200,9 @@ function shellWrites(file, pluginId) {
179
200
  const { operands: paths, mode } = operands(words)
180
201
  for (const path of paths) rows.push(row(file, line, path, command, pluginId, mode))
181
202
  } else if (["cp", "mv", "install", "ln"].includes(command)) {
182
- const { operands: paths, mode } = operands(words)
183
- if (paths.length >= 2) rows.push(row(file, line, paths[paths.length - 1], command, pluginId, mode))
203
+ const { operands: paths, mode, target } = operands(words, { targets: true })
204
+ if (target !== null && paths.length >= 1) rows.push(row(file, line, target, command, pluginId, mode))
205
+ else if (paths.length >= 2) rows.push(row(file, line, paths[paths.length - 1], command, pluginId, mode))
184
206
  } else if (command === "mktemp") {
185
207
  const { operands: paths } = operands(words)
186
208
  const template = paths[0] || (words.includes("-p") ? `${words[words.indexOf("-p") + 1]}/tmp.XXXXXX` : "/tmp/tmp.XXXXXX")
@@ -1,12 +1,15 @@
1
- // `omakit lab inspect`, and the lab lines of `omakit doctor`: what the pin
2
- // advertises, what is on disk, whether it was verified, what the host is
3
- // missing, and what each missing thing costs. Read-only: no directory is
4
- // created, no file is written, nothing is fetched. `--verify` re-hashes
5
- // the ISO and re-checks the signature now instead of reporting the record.
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: missing, ready, mismatch (a base of another release than the
69
- * pin), or invalid (files without a complete manifest, or a disk whose
70
- * size is not the recorded one). Never booted here. `allocatedBytes` is
71
- * `du -B1` over the directory, the figure prune will recover.
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
- if (!manifest || manifest.state !== "ready" || !manifest.release || !manifest.guest || !manifest.disk) {
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
- if (manifest.release.sha256 !== pin.release.sha256 || manifest.guest.version !== pin.release.expectedGuestVersion) {
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 = `cached Omarchy ${manifest.release.name} (guest ${manifest.guest.version}, iso ${manifest.release.sha256}); required ${pin.release.name} (guest ${pin.release.expectedGuestVersion}, iso ${pin.release.sha256}); \`omakit lab setup\` replaces it`
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 = "ready"
110
- out.reason = `Omarchy ${manifest.release.name}, guest omarchy ${manifest.guest.version}; ${bytesBoth(out.allocatedBytes)}; created ${manifest.createdAt}`
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
- /** Staging directories, each with whether a QEMU still answers on its socket (the cross-namespace liveness question). */
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 alive = existsSync(socket) ? (await qmpAlive(socket)).alive : false
174
- entries.push({ name, dir, allocatedBytes: allocatedBytes(dir), alive, socket: existsSync(socket) ? socket : null })
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. `host` is the
198
- * run probes plus what acquisition and a build need; `missing` is the list
199
- * of what stands between this host and a run, each with its cost and the
200
- * one command.
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
- if (!download.verified) {
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: download.present ? `verification of the ${bytesBoth(download.bytes)} on disk (about ${durationWords(5215 + 9430)} on the reference host: the hash and the signature check)` : `a download of ${bytesBoth(pin.release.bytes)} from ${pin.release.isoUrl}, verified against the pinned sha256 and the Omarchy signature, into ${download.dir}`,
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" && base.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 (base.state !== "ready") {
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
- return { pin: { release: pin.release, toolchain: pin.toolchain, guest: pin.guest, measured: pin.measured }, layout, download, base, toolchain, staging, lock, host, free, runs, totals, missing }
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
  }
@@ -1,19 +1,13 @@
1
1
  {
2
- "schema": 1,
3
- "release": {
4
- "name": "4.0.3",
5
- "embeddedBuild": "2026.09.08",
6
- "volume": "OMARCHY_202609",
7
- "isoUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso",
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; packaging/LAB_PLAN.md M4 to M7 are the 2026-09-10 to 2026-09-13 observations it re-measured"
36
+ "source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host with Omarchy 4.0.3; packaging/LAB_PLAN.md M4 to M7 are the 2026-09-10 to 2026-09-13 observations it re-measured"
41
37
  }
42
38
  }