omakit 0.5.1 → 0.6.2
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/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- 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(
|
|
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:
|
|
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
|
package/tools/weigh/audit.mjs
CHANGED
|
@@ -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.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 }}
|
|
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
|
package/tools/weigh/config.mjs
CHANGED
|
@@ -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
|
|
61
|
-
* the md5. A missing file is recorded as such
|
|
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}
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
86
|
-
|
|
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
|
|
91
|
-
* shell has been restarted on them: a shell that rewrites the file as
|
|
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 }}
|
|
176
|
+
* @param {{ configFile: string, bytes: Buffer|null, mode: number, consented: true }} lease
|
|
96
177
|
*/
|
|
97
|
-
export function restoreConfig(
|
|
98
|
-
|
|
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
|
-
|
|
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 }}
|
|
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(
|
|
119
|
-
const { configFile, backupFile, bytes, md5Before } =
|
|
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
|
-
|
package/tools/weigh/list.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|