omakit 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -32
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +7 -4
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +37 -6
  50. package/tools/inspect/functions.mjs +236 -12
  51. package/tools/inspect/helpers.mjs +217 -0
  52. package/tools/inspect/inspect.mjs +71 -5
  53. package/tools/inspect/measure-functions.mjs +12 -3
  54. package/tools/inspect/patterns.mjs +35 -18
  55. package/tools/inspect/processes.mjs +38 -5
  56. package/tools/inspect/report.mjs +33 -6
  57. package/tools/inspect/writes.mjs +22 -4
  58. package/tools/lab/guest.mjs +155 -0
  59. package/tools/lab/harness.sh +119 -0
  60. package/tools/lab/host.mjs +177 -0
  61. package/tools/lab/inspect.mjs +240 -0
  62. package/tools/lab/omarchy.gpg +13 -0
  63. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  64. package/tools/lab/paths.mjs +173 -0
  65. package/tools/lab/pin.json +42 -0
  66. package/tools/lab/pin.mjs +64 -0
  67. package/tools/lab/prune.mjs +68 -0
  68. package/tools/lab/qemu.mjs +153 -0
  69. package/tools/lab/qmp-cli.mjs +21 -0
  70. package/tools/lab/report.mjs +183 -0
  71. package/tools/lab/run.mjs +344 -0
  72. package/tools/lab/setup.mjs +430 -0
  73. package/tools/lab/suites/run.sh +35 -0
  74. package/tools/lab/suites/store.sh +41 -0
  75. package/tools/lab/suites/weigh.sh +196 -0
  76. package/tools/lab/suites.mjs +142 -0
  77. package/tools/lab/verify.mjs +134 -0
  78. package/tools/marketplace/README.md +38 -1
  79. package/tools/marketplace/banner.mjs +23 -2
  80. package/tools/marketplace/cli.mjs +449 -146
  81. package/tools/marketplace/completion-check.mjs +27 -1
  82. package/tools/marketplace/completion.mjs +32 -4
  83. package/tools/marketplace/doctor.mjs +47 -9
  84. package/tools/marketplace/github.mjs +52 -6
  85. package/tools/marketplace/local-transport.mjs +1 -1
  86. package/tools/marketplace/options.mjs +16 -5
  87. package/tools/marketplace/outcome.mjs +244 -0
  88. package/tools/marketplace/pin.mjs +178 -33
  89. package/tools/marketplace/setup.mjs +16 -15
  90. package/tools/marketplace/tree.mjs +1 -1
  91. package/tools/marketplace/upgrade.mjs +5 -5
  92. package/tools/marketplace/usage.mjs +116 -72
  93. package/tools/subject/resolve.mjs +19 -6
  94. package/tools/weigh/audit.mjs +47 -16
  95. package/tools/weigh/config.mjs +105 -24
  96. package/tools/weigh/list.mjs +10 -1
@@ -3,9 +3,9 @@
3
3
  // reviewer mode - <https url>@<40-char sha>, fetched read-only into
4
4
  // .cache/subjects/<owner>__<repo>/ and never executed.
5
5
  import { execFileSync } from "node:child_process"
6
- import { existsSync, mkdirSync } from "node:fs"
6
+ import { existsSync, realpathSync, statSync, mkdirSync } from "node:fs"
7
7
  import { homedir } from "node:os"
8
- import { join, resolve } from "node:path"
8
+ import { dirname, join, resolve } from "node:path"
9
9
 
10
10
  export class SubjectError extends Error {
11
11
  constructor(code, message) {
@@ -15,7 +15,7 @@ export class SubjectError extends Error {
15
15
  }
16
16
 
17
17
  function git(dir, args) {
18
- return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
18
+ return execFileSync("git", ["-C", dir, ...args], { timeout: 300_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
19
19
  }
20
20
 
21
21
  /** "https://github.com/owner/repo(.git)" -> { owner, repository, url } or null. */
@@ -62,9 +62,22 @@ export function resolveSubject(target, options) {
62
62
  const parsed = parseTarget(target)
63
63
  if (parsed.mode === "author") {
64
64
  if (!existsSync(parsed.path)) throw new SubjectError("subject-not-found", `no such directory: ${parsed.path}`)
65
+ // A file where a directory is meant is said so: `git -C <file>` fails
66
+ // and read as "not inside a Git repository" although it was (measured
67
+ // on 2026-09-19 by a first user passing a plugin's README.md;
68
+ // docs/evidence/ux/2026-09-19-first-user-test.json, finding 7).
69
+ if (!statSync(parsed.path).isDirectory()) throw new SubjectError("not-a-directory", `${parsed.path} is a file; the target is the plugin's repository directory, the one with its manifest.json (${dirname(parsed.path)}?)`)
70
+ // The real path, once, here: git reports the top level as a real path,
71
+ // and a target reached through a symbolic link compared against it
72
+ // made a subdirectory that does not exist. Measured on 2026-09-19:
73
+ // `inspect /tmp/tm-link` (a link to /tmp/tm) looked for a manifest at
74
+ // /tmp/tm/link and exited 2 while verify and submit, which read the
75
+ // top level alone, resolved it (docs/evidence/ux/2026-09-19-acceptance.json,
76
+ // finding 5). Every command resolves its subject through this function.
77
+ const real = realpathSync(parsed.path)
65
78
  let top
66
79
  try {
67
- top = git(parsed.path, ["rev-parse", "--show-toplevel"]).trim()
80
+ top = git(real, ["rev-parse", "--show-toplevel"]).trim()
68
81
  } catch {
69
82
  throw new SubjectError("not-a-git-repository", `${parsed.path} is not inside a Git repository`)
70
83
  }
@@ -90,7 +103,7 @@ export function resolveSubject(target, options) {
90
103
  return {
91
104
  mode: "author",
92
105
  dir: top,
93
- subdir: resolve(parsed.path) === top ? "" : resolve(parsed.path).slice(top.length + 1),
106
+ subdir: real === top ? "" : real.startsWith(`${top}/`) ? real.slice(top.length + 1) : "",
94
107
  commit,
95
108
  clean,
96
109
  uncommittedFiles: status.length,
@@ -102,7 +115,7 @@ export function resolveSubject(target, options) {
102
115
  const dir = join(resolve(options.cacheRoot), "subjects", `${gh.owner}__${gh.repository}`)
103
116
  if (!existsSync(join(dir, ".git"))) {
104
117
  mkdirSync(dir, { recursive: true })
105
- execFileSync("git", ["init", "-q", dir], { encoding: "utf8" })
118
+ execFileSync("git", ["init", "-q", dir], { timeout: 60_000, encoding: "utf8" })
106
119
  git(dir, ["remote", "add", "origin", gh.url])
107
120
  }
108
121
  let present = false
@@ -3,15 +3,19 @@
3
3
  // `planWeigh()` reads and decides: the shell that runs, whether the session is
4
4
  // locked, what is installed and enabled, which plugins will be measured, how
5
5
  // many restarts that is and how long it will take. It writes nothing, so the
6
- // confirmation is made from it and a refusal costs nothing.
6
+ // confirmation is made from it and a refusal costs nothing; a backup an
7
+ // earlier measurement left beside shell.json is a refusal too.
7
8
  //
8
9
  // `measureWeigh()` is the half that changes the user's machine, and the only
9
10
  // one in omakit that does. For every run: write a configuration, restart the
10
11
  // shell, wait until every installed plugin is reported, settle, sample; then
11
- // the next configuration. The backup is taken before the first write and
12
- // restored in a `finally` that every exit path passes through: a completed
13
- // run, a restart that did not answer, a thrown error, and an interrupt, which
14
- // arrives here as an aborted signal rather than a dead process.
12
+ // the next configuration. It takes the consent as an argument and refuses
13
+ // without it. The backup is taken before the first write and restored in a
14
+ // `finally` that every exit path passes through: a completed run, a restart
15
+ // that did not answer, a thrown error, and an interrupt (SIGINT, SIGTERM or
16
+ // SIGHUP), which arrives here as an aborted signal rather than a dead
17
+ // process. A SIGKILL is the one exit no code runs on: it leaves the backup
18
+ // beside shell.json, and the next `planWeigh` names it.
15
19
  //
16
20
  // The method is a port of the audit described in docs/WEIGH.md, and that
17
21
  // document is the contract for what comes out; the bash it was ported from
@@ -23,7 +27,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
23
27
  import { hostname } from "node:os"
24
28
  import { dirname, join, resolve } from "node:path"
25
29
  import { run } from "./commands.mjs"
26
- import { backupConfig, configPaths, md5, restoreConfig, verifyRestore, without, writeConfig } from "./config.mjs"
30
+ import { backupConfig, backupsBeside, configPaths, md5, restoreConfig, verifyRestore, without, writeConfig } from "./config.mjs"
27
31
  import { childTicks, cpuTicks, descendants, PROC, pssKb, rssKb } from "./proc.mjs"
28
32
  import { median, stats, tickPercent, verdict } from "./stats.mjs"
29
33
  import { omakitStateDir } from "../marketplace/paths.mjs"
@@ -218,6 +222,18 @@ export function planWeigh({ target, all = false, runs = DEFAULTS.runs, windowSec
218
222
  audited = audited.map((plugin) => ({ id: plugin.id, name: plugin.name || plugin.id, kinds: plugin.kinds || [], firstParty: plugin.firstParty === true, sourceDir: sourceDirOf(plugin.id) }))
219
223
 
220
224
  const { file: configFile } = configPaths(env)
225
+ // A backup already beside shell.json is a measurement that never reached
226
+ // its verification, or one whose restore did not verify: the person has
227
+ // not seen it yet, and a second measurement over it would bury it. So it
228
+ // is named, with the restore, and nothing starts until it is gone.
229
+ // Measured on 2026-09-19: a second consented run started 3 m 14 s after
230
+ // the first had died with its backup in place, and the backup was found
231
+ // by a third party the next hour (docs/evidence/ux/2026-09-19-acceptance.json, finding 1).
232
+ const backups = backupsBeside(configFile)
233
+ if (backups.length) {
234
+ const newest = backups.at(-1)
235
+ throw new WeighError("backup-present", `${newest.file} is a backup a measurement started at ${newest.startedAt} left beside ${configFile}${backups.length > 1 ? ` (${backups.length} such backups)` : ""}; the measurement did not finish its restore, or the restore did not verify, and nothing is weighed over it`, `Compare it with ${configFile}; if the difference is not yours, \`cp ${newest.file} ${configFile}\` and run omarchy-restart-shell; then remove the backup${backups.length > 1 ? "s" : ""} and run weigh again.`)
236
+ }
221
237
  const stateDir = omakitStateDir("weigh", env)
222
238
  const timing = restartTiming(stateDir)
223
239
  const restarts = (1 + audited.length) * runs
@@ -250,10 +266,17 @@ export function planWeigh({ target, all = false, runs = DEFAULTS.runs, windowSec
250
266
  }
251
267
  }
252
268
 
269
+ /** The stop as an error: `interrupted`, carrying the signal name the controller was aborted with (`SIGINT`, `SIGTERM`, `SIGHUP`) so the exit status can follow it. */
270
+ function interrupted(signal) {
271
+ const error = new WeighError("interrupted", "interrupted")
272
+ error.signal = typeof signal?.reason === "string" ? signal.reason : null
273
+ return error
274
+ }
275
+
253
276
  function sleep(ms, signal) {
254
277
  return new Promise((resolveSleep, reject) => {
255
278
  if (signal?.aborted) {
256
- reject(new WeighError("interrupted", "interrupted"))
279
+ reject(interrupted(signal))
257
280
  return
258
281
  }
259
282
  const timer = setTimeout(() => {
@@ -262,14 +285,14 @@ function sleep(ms, signal) {
262
285
  }, ms)
263
286
  function onAbort() {
264
287
  clearTimeout(timer)
265
- reject(new WeighError("interrupted", "interrupted"))
288
+ reject(interrupted(signal))
266
289
  }
267
290
  signal?.addEventListener("abort", onAbort, { once: true })
268
291
  })
269
292
  }
270
293
 
271
294
  function checkAbort(signal) {
272
- if (signal?.aborted) throw new WeighError("interrupted", "interrupted")
295
+ if (signal?.aborted) throw interrupted(signal)
273
296
  }
274
297
 
275
298
  /** Wait until listPlugins reports every installed plugin, polling once a second; null when it does not within the timeout. */
@@ -306,11 +329,11 @@ function shellPid(omarchyPath, env) {
306
329
  * the sample, or `{ failed }` with the reason when the shell did not come
307
330
  * back or did not report every plugin; nothing is estimated in its place.
308
331
  */
309
- async function sampleConfig({ label, runIndex, config, plan, env, procRoot, signal, onPhase, restartTimes }) {
332
+ async function sampleConfig({ label, runIndex, config, plan, lease, env, procRoot, signal, onPhase, restartTimes }) {
310
333
  const { configFile, omarchyPath, windowSeconds, settleSeconds, readyTimeoutSeconds, sampleIntervalMs, clockTicksPerSecond: clk } = plan
311
334
  const expectedCount = plan.installed.length
312
335
  onPhase(`run ${runIndex} of ${plan.runs}: ${label}, restarting the shell`)
313
- writeConfig(configFile, config)
336
+ writeConfig(lease, config)
314
337
  const restartStarted = Date.now()
315
338
  const restart = run("restartShell", { env, timeoutMs: 120_000 })
316
339
  if (!restart.ok) return { label, run: runIndex, failed: "the shell did not answer after the restart" }
@@ -589,11 +612,19 @@ export function buildDocument(plan, samples, { started, ended, config, host = ho
589
612
  * the user's own configuration; the document records the md5 before and
590
613
  * after and whether they matched.
591
614
  *
615
+ * The consent is an argument, not a flag read somewhere else: `{ consented:
616
+ * true, how }` is what cli.mjs hands over after `--yes` or a `y` at the
617
+ * terminal, and without it nothing here writes; `backupConfig` refuses
618
+ * too, so no caller can reach shell.json around this function.
619
+ *
592
620
  * @param {ReturnType<typeof planWeigh>} plan
593
- * @param {{ env?: NodeJS.ProcessEnv, procRoot?: string, signal?: AbortSignal, omakitVersion?: string,
594
- * onPhase?: (text: string) => void, onLine?: (line: { state: string, text: string }) => void }} [options]
621
+ * @param {{ consent: { consented: boolean, how?: string }, env?: NodeJS.ProcessEnv, procRoot?: string, signal?: AbortSignal, omakitVersion?: string,
622
+ * onPhase?: (text: string) => void, onLine?: (line: { state: string, text: string }) => void }} options
595
623
  */
596
- export async function measureWeigh(plan, { env = plan.env || process.env, procRoot = PROC, signal, omakitVersion = "unknown", onPhase = () => {}, onLine = () => {} } = {}) {
624
+ export async function measureWeigh(plan, { consent, env = plan.env || process.env, procRoot = PROC, signal, omakitVersion = "unknown", onPhase = () => {}, onLine = () => {} } = {}) {
625
+ if (consent?.consented !== true) throw new WeighError("not-confirmed", "no consented measurement is running, so shell.json is not touched", "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.")
626
+ // The backup's name is the UTC second the measurement began: the moment
627
+ // shell.json was first touched, readable from the file name alone.
597
628
  const stamp = fileStamp().replace(/[-T]/g, "").replace(/Z$/, "")
598
629
  const ids = plan.audited.map((plugin) => plugin.id)
599
630
  plan.baselineConfig = without(plan.effective, ids, plan.installed)
@@ -602,7 +633,7 @@ export async function measureWeigh(plan, { env = plan.env || process.env, procRo
602
633
  configs.push({ label: plugin.id, config: without(plan.effective, ids.filter((id) => id !== plugin.id), plan.installed) })
603
634
  }
604
635
  mkdirSync(dirname(plan.configFile), { recursive: true })
605
- const backup = backupConfig(plan.configFile, stamp)
636
+ const backup = backupConfig(plan.configFile, stamp, consent)
606
637
  onLine({ state: "info", text: backup.bytes === null
607
638
  ? `${plan.configFile} does not exist; it will be removed again afterwards`
608
639
  : `${plan.configFile} backed up to ${backup.backupFile}, md5 ${backup.md5Before}` })
@@ -622,7 +653,7 @@ export async function measureWeigh(plan, { env = plan.env || process.env, procRo
622
653
  for (let runIndex = 1; runIndex <= plan.runs; runIndex += 1) {
623
654
  for (const { label, config } of configs) {
624
655
  checkAbort(signal)
625
- const sample = await sampleConfig({ label, runIndex, config, plan, env, procRoot, signal, onPhase, restartTimes })
656
+ const sample = await sampleConfig({ label, runIndex, config, plan, lease: backup, env, procRoot, signal, onPhase, restartTimes })
626
657
  // One line per configuration, on the record: a forty-restart run
627
658
  // in a pipe would otherwise be silent for half an hour, and the
628
659
  // figures here are the raw samples a reader can check the medians
@@ -8,10 +8,22 @@
8
8
  // its bytes are read once and written to a timestamped backup beside it, and
9
9
  // those same bytes are written back over it on the way out. Nothing here
10
10
  // re-serialises what the user wrote.
11
+ //
12
+ // Nothing here writes without a lease, and a lease is opened only with the
13
+ // person's consent in hand: `backupConfig()` refuses `consented: true`
14
+ // absent, and the two writes that follow take the lease it returned, so
15
+ // there is no order of calls in which a measurement configuration reaches
16
+ // shell.json before its backup exists or before anyone agreed. Measured on
17
+ // 2026-09-19: two omakit backups appeared beside a person's shell.json in a
18
+ // window when they had authorised no weighing on their own machine; the
19
+ // writes came from consented runs in another session, but the code allowed
20
+ // a write with nothing but a path (docs/evidence/ux/2026-09-19-acceptance.json,
21
+ // finding 1). Every write is a whole file renamed into place, so a process
22
+ // killed mid-write leaves the old file whole, never a truncated one.
11
23
 
12
24
  import { createHash } from "node:crypto"
13
- import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"
14
- import { join } from "node:path"
25
+ import { closeSync, existsSync, fsyncSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
26
+ import { basename, dirname, join } from "node:path"
15
27
 
16
28
  /** Where Omarchy keeps the shell configuration: `~/.config/omarchy/shell.json`, the path the shell itself reads. */
17
29
  export function configPaths(env = process.env) {
@@ -24,6 +36,36 @@ export function md5(bytes) {
24
36
  return createHash("md5").update(bytes).digest("hex")
25
37
  }
26
38
 
39
+ /** The name every backup carries: `shell.json.omakit-backup-<UTC stamp, YYYYMMDDHHMMSS>`, the second the measurement began. */
40
+ export const BACKUP_SUFFIX = ".omakit-backup-"
41
+ const BACKUP_NAME = /\.omakit-backup-(\d{14})$/
42
+
43
+ /**
44
+ * The backups already beside the configuration, oldest first: each one is
45
+ * a measurement that did not reach its verification, or one whose restore
46
+ * did not verify, and `planWeigh` refuses to start another until the
47
+ * person has looked at it (docs/WEIGH.md).
48
+ *
49
+ * @returns {{ file: string, stamp: string, startedAt: string }[]}
50
+ */
51
+ export function backupsBeside(configFile) {
52
+ const dir = dirname(configFile)
53
+ const name = basename(configFile)
54
+ let entries = []
55
+ try {
56
+ entries = readdirSync(dir)
57
+ } catch {
58
+ return []
59
+ }
60
+ return entries
61
+ .filter((entry) => entry.startsWith(`${name}${BACKUP_SUFFIX}`) && BACKUP_NAME.test(entry))
62
+ .sort()
63
+ .map((entry) => {
64
+ const stamp = entry.match(BACKUP_NAME)[1]
65
+ return { file: join(dir, entry), stamp, startedAt: `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(8, 10)}:${stamp.slice(10, 12)}:${stamp.slice(12, 14)}Z` }
66
+ })
67
+ }
68
+
27
69
  /**
28
70
  * The effective configuration with a set of plugin ids removed: every bar
29
71
  * layout entry with that id, every `plugins[]` entry with that id, and for
@@ -55,47 +97,87 @@ export function without(config, ids, installed) {
55
97
  return out
56
98
  }
57
99
 
100
+ /** A refusal from this module: no lease, or a lease nobody consented to. */
101
+ export class ConsentError extends Error {
102
+ constructor(message) {
103
+ super(message)
104
+ this.name = "ConsentError"
105
+ this.code = "not-confirmed"
106
+ }
107
+ }
108
+
109
+ function requireLease(lease, what) {
110
+ if (!lease || lease.consented !== true || typeof lease.configFile !== "string" || typeof lease.backupFile !== "string") {
111
+ throw new ConsentError(`${what} needs the lease backupConfig() returns after consent; nothing was written`)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The whole file, then the name: written to `<target>.part` beside its
117
+ * target, fsynced, and renamed into place, so at every instant the target
118
+ * is either the old file or the new one. The mode is set on the part file
119
+ * before the rename, so a 0600 shell.json stays 0600.
120
+ */
121
+ function writeWhole(target, bytes, mode) {
122
+ const part = `${target}.part`
123
+ const fd = openSync(part, "w", mode)
124
+ try {
125
+ writeFileSync(fd, bytes)
126
+ fsyncSync(fd)
127
+ } finally {
128
+ closeSync(fd)
129
+ }
130
+ renameSync(part, target)
131
+ }
132
+
58
133
  /**
59
134
  * Read the configuration file and write its bytes to a timestamped backup
60
- * beside it. Returns what the restore needs: the bytes, the backup path and
61
- * the md5. A missing file is recorded as such and restored by removal.
135
+ * beside it. Returns the lease every later write needs: the bytes, the
136
+ * backup path, the md5 and the mode. A missing file is recorded as such
137
+ * and restored by removal. Refuses without `consented: true`.
62
138
  *
63
139
  * @param {string} configFile
64
140
  * @param {string} stamp UTC, `YYYYMMDDHHMMSS`
141
+ * @param {{ consented: boolean }} consent
65
142
  */
66
- export function backupConfig(configFile, stamp) {
143
+ export function backupConfig(configFile, stamp, { consented = false } = {}) {
144
+ if (consented !== true) throw new ConsentError("a backup of shell.json is the first write of a measurement, and no measurement was consented to; nothing was written")
67
145
  let bytes = null
146
+ let mode = 0o600
68
147
  try {
69
148
  bytes = readFileSync(configFile)
149
+ // The same mode as the original: shell.json is 0600 on an Omarchy
150
+ // install, and a copy of a private file is a private file.
151
+ mode = statSync(configFile).mode & 0o777
70
152
  } catch (error) {
71
153
  if (error.code !== "ENOENT") throw error
72
154
  }
73
- const backupFile = `${configFile}.omakit-backup-${stamp}`
74
- // The same mode as the original: shell.json is 0600 on an Omarchy install,
75
- // and a copy of a private file is a private file.
76
- if (bytes !== null) writeFileSync(backupFile, bytes, { mode: statSync(configFile).mode & 0o777 })
77
- return { configFile, backupFile, bytes, md5Before: bytes === null ? null : md5(bytes) }
155
+ const backupFile = `${configFile}${BACKUP_SUFFIX}${stamp}`
156
+ if (bytes !== null) writeWhole(backupFile, bytes, mode)
157
+ return { configFile, backupFile, bytes, mode, md5Before: bytes === null ? null : md5(bytes), consented: true }
78
158
  }
79
159
 
80
160
  /**
81
161
  * Write one configuration for a run. The document is serialised the way
82
162
  * `jq .` would, two-space indented, so the shell reads exactly what the
83
- * transform produced.
163
+ * transform produced. Takes the lease, never a bare path.
84
164
  */
85
- export function writeConfig(configFile, config) {
86
- writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`)
165
+ export function writeConfig(lease, config) {
166
+ requireLease(lease, "writing a measurement configuration")
167
+ writeWhole(lease.configFile, Buffer.from(`${JSON.stringify(config, null, 2)}\n`), lease.mode)
87
168
  }
88
169
 
89
170
  /**
90
- * Put the user's bytes back. Verification is a separate step, after the
91
- * shell has been restarted on them: a shell that rewrites the file as it
92
- * starts is exactly what the md5 has to catch, so it is read after the
171
+ * Put the user's bytes back, whole. Verification is a separate step, after
172
+ * the shell has been restarted on them: a shell that rewrites the file as
173
+ * it starts is exactly what the md5 has to catch, so it is read after the
93
174
  * restart and not before.
94
175
  *
95
- * @param {{ configFile: string, bytes: Buffer|null }} backup
176
+ * @param {{ configFile: string, bytes: Buffer|null, mode: number, consented: true }} lease
96
177
  */
97
- export function restoreConfig(backup) {
98
- const { configFile, bytes } = backup
178
+ export function restoreConfig(lease) {
179
+ requireLease(lease, "restoring shell.json")
180
+ const { configFile, bytes, mode } = lease
99
181
  if (bytes === null) {
100
182
  try {
101
183
  unlinkSync(configFile)
@@ -104,7 +186,7 @@ export function restoreConfig(backup) {
104
186
  }
105
187
  return
106
188
  }
107
- writeFileSync(configFile, bytes)
189
+ writeWhole(configFile, bytes, mode)
108
190
  }
109
191
 
110
192
  /**
@@ -112,11 +194,11 @@ export function restoreConfig(backup) {
112
194
  * of the backup. The backup is removed only when they are equal; otherwise
113
195
  * it stays and the caller says so.
114
196
  *
115
- * @param {{ configFile: string, backupFile: string, bytes: Buffer|null, md5Before: string|null }} backup
197
+ * @param {{ configFile: string, backupFile: string, bytes: Buffer|null, md5Before: string|null }} lease
116
198
  * @returns {{ md5After: string|null, restored: boolean, backupRemoved: boolean }}
117
199
  */
118
- export function verifyRestore(backup) {
119
- const { configFile, backupFile, bytes, md5Before } = backup
200
+ export function verifyRestore(lease) {
201
+ const { configFile, backupFile, bytes, md5Before } = lease
120
202
  if (bytes === null) return { md5After: null, restored: !existsSync(configFile), backupRemoved: false }
121
203
  let md5After = null
122
204
  try {
@@ -128,4 +210,3 @@ export function verifyRestore(backup) {
128
210
  if (restored) unlinkSync(backupFile)
129
211
  return { md5After, restored, backupRemoved: restored }
130
212
  }
131
-
@@ -51,7 +51,16 @@ export function lastWeighings(stateDir) {
51
51
  */
52
52
  export function installedPlugins({ env = process.env } = {}) {
53
53
  const listed = run("listPlugins", { env })
54
- if (!listed.ok) throw new WeighError("shell-not-running", "omarchy plugin list did not answer, and the list is what the shell has installed", "omarchy-restart-shell")
54
+ // The reason travels with the refusal: what the command said on stderr
55
+ // and how it exited. Measured on 2026-09-19 by a first user whose piped
56
+ // `omakit audit` reported only "did not answer" while the unpiped one
57
+ // audited 19 plugins; without the child's own words nothing could tell
58
+ // the two apart (docs/evidence/ux/2026-09-19-first-user-test.json).
59
+ if (!listed.ok) {
60
+ const said = String(listed.stderr || "").trim().split("\n").filter(Boolean).at(-1)
61
+ const how = listed.missing ? "omarchy is not on PATH" : `exit ${listed.status === null ? "on a signal or the 30 s deadline" : listed.status}${said ? `: ${said}` : ", nothing on stderr"}`
62
+ throw new WeighError("shell-not-running", `omarchy plugin list did not answer (${how}), and the list is what the shell has installed`, listed.missing ? "Run this on an Omarchy machine, with omarchy on PATH." : "omarchy-restart-shell, or run it from the session: it needs OMARCHY_PATH and the Wayland display the session sets.")
63
+ }
55
64
  let installed
56
65
  try {
57
66
  installed = JSON.parse(listed.stdout)