omakit 0.5.1 → 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 (94) hide show
  1. package/README.md +38 -45
  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 +6 -3
  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 +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/bash
2
+ # The lab suite for the Store block: every hostile condition the contract
3
+ # refuses, planted in a throwaway HOME, and the block run against it in a
4
+ # separate quickshell instance (systemd-run --user --scope -p MemoryMax=768M,
5
+ # setsid when the user manager is not reachable), never in omarchy-shell.
6
+ # Per scenario the driver prepares HOME, starts the harness with that HOME,
7
+ # waits for its results, then records what the filesystem looks like
8
+ # afterwards; tests/lab/store/report.py asserts the states and those facts.
9
+ #
10
+ # bash tests/lab/store/suite.sh [out-dir] default /tmp/omakit-storelab
11
+ # RUNLAB_BLOCKS=<dir> the blocks/ directory to test
12
+ set -u
13
+ here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
14
+ repo="$(cd -- "$here/../../.." && pwd)"
15
+ blocks="${RUNLAB_BLOCKS:-$repo/blocks}"
16
+ out="${1:-/tmp/omakit-storelab}"
17
+ scenarios=(plain symlink-directory symlink-parent symlink-file swap oversized group-writable foreign-owner invalid crash concurrent outside-home fifo short-write non-ascii)
18
+ plugin=lab.store.fixture
19
+
20
+ for tool in /usr/bin/python3 /usr/bin/quickshell; do [[ -x $tool ]] || { echo "not ok - $tool is missing" >&2; exit 1; }; done
21
+ [[ -n ${WAYLAND_DISPLAY:-} ]] || { echo "not ok - no WAYLAND_DISPLAY" >&2; exit 1; }
22
+ rm -rf "$out" && mkdir -p "$out/runs" "$out/harness/omakit" "$out/homes" || exit 1
23
+ cp "$here/harness/shell.qml" "$out/harness/" && cp "$blocks"/run/Run.qml "$blocks"/run/run-supervisor.py "$blocks"/store/Store.qml "$blocks"/store/store-helper.py "$out/harness/omakit/" || exit 1
24
+ scope_ok=0; systemd-run --user --scope --quiet -p MemoryMax=768M -- /usr/bin/true >/dev/null 2>&1 && scope_ok=1
25
+ sudo_ok=0; sudo -n /usr/bin/chown --version >/dev/null 2>&1 && sudo_ok=1
26
+ echo "# scope: $([[ $scope_ok == 1 ]] && echo systemd-run || echo setsid); sudo for the foreign owner: $([[ $sudo_ok == 1 ]] && echo yes || echo no)"
27
+
28
+ count_ev() { grep -o 'STORELAB {.*}' "$log" | grep -c "\"ev\":\"$1\""; }
29
+ waitev() { local n=0; while [[ $(count_ev "$1") -lt 1 ]]; do sleep 0.05; n=$((n+1)); [[ $n -gt $(( $2 * 20 )) ]] && return 1; done; return 0; }
30
+
31
+ prepare() {
32
+ # A fresh HOME, 0700, with the state and cache bases; the victim outside it.
33
+ local scen=$1 home=$2 dir=$2/.local/state/$plugin
34
+ mkdir -m 0700 "$home" && mkdir -p -m 0700 "$home/.local" "$home/.local/state" "$home/.cache" || return 1
35
+ mkdir -p "$out/victims" && printf 'untouched\n' > "$out/victims/$scen"
36
+ case $scen in
37
+ symlink-directory) ln -s "$out/victims" "$dir" ;;
38
+ symlink-parent) rm -rf "$home/.local/state" && ln -s "$out/victims" "$home/.local/state" ;;
39
+ symlink-file) mkdir -m 0700 "$dir" && ln -s "$out/victims/$scen" "$dir/memory.json" ;;
40
+ swap) mkdir -m 0700 "$dir" && printf '{"version":1,"themes":{}}\n' > "$dir/memory.json" ;;
41
+ oversized) mkdir -p -m 0700 "$home/.cache/$plugin" && /usr/bin/head -c 2097152 /dev/zero | tr '\0' 'x' > "$home/.cache/$plugin/catalog.json" ;;
42
+ group-writable) mkdir -m 0700 "$dir" && printf '{"version":1,"themes":{}}\n' > "$dir/memory.json" && chmod 0660 "$dir/memory.json" ;;
43
+ foreign-owner) mkdir -m 0700 "$dir" && printf '{"version":1,"themes":{}}\n' > "$dir/memory.json" && { [[ $sudo_ok == 0 ]] || sudo -n chown root "$dir/memory.json"; } ;;
44
+ invalid) mkdir -m 0700 "$dir" && printf '{"version":"one","themes":{}}\n' > "$dir/memory.json" ;;
45
+ crash) mkdir -m 0700 "$dir" && printf '{"version":1,"themes":{}}\n' > "$dir/memory.json" \
46
+ && printf '{"version":9' > "$dir/.store-99999-0123456789abcdef.tmp" && touch -d '-1 hour' "$dir/.store-99999-0123456789abcdef.tmp" \
47
+ && printf '{"version":9' > "$dir/.store-99998-fedcba9876543210.tmp" ;;
48
+ # 0.2.0 (docs/evidence/blocks/2026-09-18-review.json): a FIFO where the file should be; a
49
+ # write that cannot complete (RLIMIT_FSIZE 6000 bytes on the instance, see below); a file that
50
+ # is mostly non-ASCII, within the cap, whose escaped form would not be.
51
+ fifo) mkdir -m 0700 "$dir" && mkfifo -m 0600 "$dir/memory.json" ;;
52
+ short-write) mkdir -m 0700 "$dir" && printf '{"version":1,"themes":{}}\n' > "$dir/memory.json" ;;
53
+ non-ascii) mkdir -p -m 0700 "$home/.cache/$plugin" && /usr/bin/python3 -c 'import json,sys; sys.stdout.write(json.dumps({"names": ["\u00e9\u00e8\u00ea\u20ac\U0001F600" * 20] * 3000}, ensure_ascii=False))' > "$home/.cache/$plugin/catalog.json" ;;
54
+ esac
55
+ }
56
+
57
+ one() {
58
+ local scen=$1 home=$out/homes/$scen tag=$1
59
+ log=$out/runs/$tag.log; local meta=$out/runs/$tag.meta
60
+ : > "$log"; : > "$meta"
61
+ prepare "$scen" "$home" || { echo "prepare failed" >>"$meta"; return 1; }
62
+ echo "victim_dir_before=$(ls -A "$out/victims" | wc -l)" >>"$meta"
63
+ # The session's own XDG bases point into the real HOME; the instance gets
64
+ # the throwaway one and the defaults below it, unless the scenario sets one.
65
+ local -a env=(-u XDG_STATE_HOME -u XDG_CACHE_HOME STORELAB_SCENARIO="$scen" HOME="$home")
66
+ [[ $scen == outside-home ]] && env+=(XDG_STATE_HOME="$out/victims")
67
+ [[ $scen == foreign-owner && $sudo_ok == 0 ]] && echo "foreign_owner=not-simulated" >>"$meta"
68
+ [[ $scen == foreign-owner && $sudo_ok == 1 ]] && echo "foreign_owner=root" >>"$meta"
69
+ local unit=storelab-$tag-$$
70
+ if [[ $scope_ok == 1 ]]; then
71
+ systemd-run --user --scope --quiet -p MemoryMax=768M --unit="$unit" -- /usr/bin/env "${env[@]}" /usr/bin/quickshell -p "$out/harness" >"$log" 2>&1 &
72
+ else
73
+ /usr/bin/setsid /usr/bin/env "${env[@]}" /usr/bin/quickshell -p "$out/harness" >"$log" 2>&1 < /dev/null &
74
+ fi
75
+ local runner=$! swapper=""
76
+ waitev ready 30 || { echo "no ready line" >>"$meta"; kill -KILL -- -"$runner" 2>/dev/null; systemctl --user stop "$unit.scope" 2>/dev/null; return 1; }
77
+ if [[ $scen == short-write ]]; then
78
+ # RLIMIT_FSIZE 6000 bytes on the instance once it is up (its own startup
79
+ # writes are larger), inherited by the helper: the 9 KiB staging write
80
+ # comes up short and the next write raises EFBIG.
81
+ local qspid; qspid=$(grep -o 'STORELAB {.*}' "$log" | sed 's/^STORELAB //' | /usr/bin/python3 -c 'import json,sys; print(json.loads(sys.stdin.readline())["pid"])')
82
+ /usr/bin/prlimit --pid "$qspid" --fsize=6000 && echo "fsize_limit=6000 pid=$qspid" >>"$meta"
83
+ fi
84
+ if [[ $scen == swap ]]; then
85
+ # A hostile neighbour swaps the file between a regular file and a link to the victim while the block reads and writes.
86
+ ( local d=$home/.local/state/$plugin; while [[ -d $d ]]; do ln -sfn "$out/victims/swap" "$d/memory.json.lnk" && mv -T "$d/memory.json.lnk" "$d/memory.json" 2>/dev/null; printf '{"version":1,"themes":{}}\n' > "$d/memory.json.new" && mv -T "$d/memory.json.new" "$d/memory.json" 2>/dev/null; done ) &
87
+ swapper=$!
88
+ fi
89
+ waitev all-done 120 || echo "no all-done within 120 s" >>"$meta"
90
+ [[ -n $swapper ]] && { kill "$swapper" 2>/dev/null; wait "$swapper" 2>/dev/null; }
91
+ sleep 1
92
+ local dir=$home/.local/state/$plugin
93
+ { echo "victim=$(cat "$out/victims/$scen" 2>/dev/null)"
94
+ echo "victim_dir_after=$(ls -A "$out/victims" | wc -l)"
95
+ [[ -e $dir ]] && echo "dir_mode=$(stat -c %a "$dir")" && echo "dir_type=$(stat -c %F "$dir")"
96
+ [[ -e $dir/memory.json ]] && echo "file_mode=$(stat -c %a "$dir/memory.json")" && echo "file_type=$(stat -c %F "$dir/memory.json")" && echo "file_content=$(tr -d '\n ' < "$dir/memory.json" | head -c 200)"
97
+ [[ -d $dir ]] && echo "staging_left=$(ls -A "$dir" | grep -c '^\.store-')" && echo "entries=$(ls -A "$dir" | tr '\n' ' ')"
98
+ } >>"$meta" 2>/dev/null
99
+ if [[ $scope_ok == 1 ]]; then systemctl --user stop "$unit.scope" 2>/dev/null; else kill -KILL -- -"$runner" 2>/dev/null; fi
100
+ wait "$runner" 2>/dev/null
101
+ [[ $scen == foreign-owner && $sudo_ok == 1 ]] && sudo -n chown "$(id -u)" "$dir/memory.json" 2>/dev/null
102
+ return 0
103
+ }
104
+
105
+ for scen in "${scenarios[@]}"; do
106
+ one "$scen" || echo "not ok - $scen did not run" >&2
107
+ echo "# done $scen $(grep -o '"state":"[a-z-]*"' "$out/runs/$scen.log" | sort | uniq -c | tr '\n' ' ')"
108
+ done
109
+ /usr/bin/python3 "$here/report.py" "$out" "$blocks"
@@ -34,9 +34,14 @@ function registrySources(pinDir) {
34
34
  }
35
35
 
36
36
  export function strataSizes(count) {
37
- const needsFixes = Math.ceil(count / 8)
38
- const reviewRequired = Math.ceil(count / 4)
39
- return { "needs-fixes": needsFixes, "review-required": reviewRequired, passed: Math.max(0, count - needsFixes - reviewRequired) }
37
+ // ceil for the two non-passed strata, so a small corpus still has one of
38
+ // each, and never more repositories than were asked for: at --count 1
39
+ // the two ceilings alone made two (measured on 2026-09-19, "identical
40
+ // 2/2" after --count 1). The 30-repository corpus is unchanged: 4, 8, 18.
41
+ const total = Math.max(0, Math.floor(count))
42
+ const needsFixes = Math.min(Math.ceil(total / 8), total)
43
+ const reviewRequired = Math.min(Math.ceil(total / 4), total - needsFixes)
44
+ return { "needs-fixes": needsFixes, "review-required": reviewRequired, passed: total - needsFixes - reviewRequired }
40
45
  }
41
46
 
42
47
  function sample(list, count, offset) {
@@ -26,7 +26,7 @@ import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
26
26
  function shallowClone(cacheDir, repoUrl, commit) {
27
27
  const dir = join(cacheDir, subjectSlug(repoUrl))
28
28
  mkdirSync(dir, { recursive: true })
29
- const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
29
+ const run = (args) => execFileSync("git", ["-C", dir, ...args], { timeout: 120_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
30
30
  try {
31
31
  run(["rev-parse", commit + "^{commit}"])
32
32
  return dir
@@ -35,7 +35,7 @@ function shallowClone(cacheDir, repoUrl, commit) {
35
35
  }
36
36
  // Test for the clone's own .git instead of relying on an outer repository.
37
37
  if (!existsSync(join(dir, ".git"))) {
38
- execFileSync("git", ["init", "-q", dir], { encoding: "utf8" })
38
+ execFileSync("git", ["init", "-q", dir], { timeout: 120_000, encoding: "utf8" })
39
39
  run(["remote", "add", "origin", repoUrl])
40
40
  }
41
41
  run(["fetch", "-q", "--depth", "1", "origin", commit])
@@ -59,7 +59,7 @@ function digest(result) {
59
59
 
60
60
  function pinCommit(dir) {
61
61
  try {
62
- return execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim()
62
+ return execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { timeout: 120_000, encoding: "utf8" }).trim()
63
63
  } catch {
64
64
  return "unknown"
65
65
  }
@@ -67,7 +67,7 @@ function pinCommit(dir) {
67
67
 
68
68
  function generatorIdentity(repoRoot) {
69
69
  try {
70
- return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
70
+ return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { timeout: 120_000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
71
71
  } catch {
72
72
  const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
73
73
  return { omakitPackage: `${pkg.name}@${pkg.version}` }
@@ -136,7 +136,7 @@ export async function auditInstalled(options = {}) {
136
136
  try {
137
137
  installed = readers.installed({ env })
138
138
  } catch (error) {
139
- throw new AuditError(error?.code || "shell-not-running", error?.message || String(error))
139
+ throw new AuditError(error?.code || "shell-not-running", error?.message || String(error), error?.remedy || null)
140
140
  }
141
141
  let live
142
142
  try {
@@ -197,7 +197,16 @@ export async function auditInstalled(options = {}) {
197
197
  updateRouteError = error?.message || String(error)
198
198
  }
199
199
  }
200
- const drift = rows.filter((row) => row.state !== "validated")
200
+ // Three kinds of row: validated, drift (a comparison that was made and
201
+ // found the installed commit off the validated one, or a listing without
202
+ // one to compare against, or no listing), and unknown (a comparison that
203
+ // could not be made: no source directory, not a checkout, a Git question
204
+ // that failed). Unknown is never counted as drift and never said to be.
205
+ // Measured on 2026-09-19: every one of 19 rows was unknown because the
206
+ // catalog named no source directory, and the close said all 19 "run one
207
+ // it never saw" (docs/evidence/ux/2026-09-19-acceptance.json, finding 4).
208
+ const drift = rows.filter((row) => row.state !== "validated" && row.state !== "unknown")
209
+ const unknown = rows.filter((row) => row.state === "unknown")
201
210
  return {
202
211
  command: "audit",
203
212
  catalog: {
@@ -212,12 +221,14 @@ export async function auditInstalled(options = {}) {
212
221
  selected: figure(selected.length, options.target ? "target selection" : "omarchy plugin list --json length"),
213
222
  firstPartyExcluded: figure(firstPartyCount, "sourceType builtin or omarchy plugin list --json firstParty"),
214
223
  audited: figure(rows.length, "audited row count"),
215
- validated: figure(rows.length - drift.length, "rows whose state is validated"),
216
- drift: figure(drift.length, "rows whose state is not validated"),
224
+ validated: figure(rows.length - drift.length - unknown.length, "rows whose state is validated"),
225
+ drift: figure(drift.length, "rows whose state is ahead, diverged, unverified or unlisted"),
226
+ unknown: figure(unknown.length, "rows whose state is unknown: a comparison that could not be made"),
217
227
  },
218
- rows: options.drift ? drift : rows,
228
+ unknownReasons: [...new Set(unknown.map((row) => row.fact))],
229
+ rows: options.drift ? rows.filter((row) => row.state !== "validated") : rows,
219
230
  updateRoute,
220
231
  updateRouteError,
221
- ok: drift.length === 0,
232
+ ok: drift.length === 0 && unknown.length === 0,
222
233
  }
223
234
  }
@@ -4,7 +4,7 @@
4
4
  import { spawnSync } from "node:child_process"
5
5
 
6
6
  function runGit(sourceDir, args, env = process.env) {
7
- const result = spawnSync("git", ["-C", sourceDir, ...args], {
7
+ const result = spawnSync("git", ["-C", sourceDir, ...args], { timeout: 60_000,
8
8
  encoding: "utf8",
9
9
  env: { ...env, GIT_NO_LAZY_FETCH: "1" },
10
10
  stdio: ["ignore", "pipe", "pipe"],
@@ -27,7 +27,7 @@ export function readCheckout(sourceDir, { env = process.env } = {}) {
27
27
 
28
28
  /** A missing object is a local-history fact, not a failed ancestry check. */
29
29
  export function hasCommit(sourceDir, commit, { env = process.env } = {}) {
30
- const result = spawnSync("git", ["-C", sourceDir, "cat-file", "-e", commit], {
30
+ const result = spawnSync("git", ["-C", sourceDir, "cat-file", "-e", commit], { timeout: 60_000,
31
31
  encoding: "utf8",
32
32
  env: { ...env, GIT_NO_LAZY_FETCH: "1" },
33
33
  stdio: ["ignore", "pipe", "pipe"],
@@ -44,7 +44,7 @@ export function isShallow(sourceDir, { env = process.env } = {}) {
44
44
 
45
45
  /** Is a recorded commit an ancestor of the running checkout? */
46
46
  export function ancestorOf(sourceDir, commit, { env = process.env } = {}) {
47
- const result = spawnSync("git", ["-C", sourceDir, "merge-base", "--is-ancestor", commit, "HEAD"], {
47
+ const result = spawnSync("git", ["-C", sourceDir, "merge-base", "--is-ancestor", commit, "HEAD"], { timeout: 60_000,
48
48
  encoding: "utf8",
49
49
  env: { ...env, GIT_NO_LAZY_FETCH: "1" },
50
50
  stdio: ["ignore", "pipe", "pipe"],
@@ -1,4 +1,8 @@
1
1
  import { action, AUDIT_VERDICTS, colourEnabled, field, GUTTER, mark, styler, verdict, wrap } from "../marketplace/style.mjs"
2
+ import { withHomeAbbreviated } from "../marketplace/paths.mjs"
3
+
4
+ /** The checkout directory as a shell takes it: `~/...` when it has no whitespace, quoted and absolute otherwise, the way every other command prints a path under the home directory. */
5
+ const shellPath = (dir) => (/\s/.test(dir) ? JSON.stringify(dir) : withHomeAbbreviated(dir))
2
6
 
3
7
  const short = (value) => value ? String(value).slice(0, 8) : "unrecorded"
4
8
  const flagText = (flags) => flags.length ? `; ${flags.join(", ")}` : ""
@@ -16,6 +20,31 @@ function catalogText(catalog) {
16
20
  return `pin ${short(catalog.commit.value)}${catalog.offline ? " (offline)" : ""}`
17
21
  }
18
22
 
23
+ /**
24
+ * The closing sentence, the same one the envelope carries as the error's
25
+ * message when the exit is 1: what was compared and found validated, what
26
+ * was compared and found off (drift), and what could not be compared, with
27
+ * why, each clause only when its count is not zero. A row that could not
28
+ * be compared is never said to run a commit the marketplace "never saw".
29
+ */
30
+ export function auditSummary(document) {
31
+ const total = document.counts.audited.value
32
+ const good = document.counts.validated.value
33
+ const drift = document.counts.drift.value
34
+ const unknown = document.counts.unknown?.value ?? 0
35
+ if (total === 0) return "no third-party plugin to audit."
36
+ const parts = [`${good} of ${total} run a commit the marketplace validated`]
37
+ if (drift) parts.push(`${drift} run one it never saw`)
38
+ if (unknown) parts.push(`${unknown} could not be compared (${(document.unknownReasons || []).join("; ") || "the source directory could not be read"})`)
39
+ return `${parts.join("; ")}.`
40
+ }
41
+
42
+ /** The closing word: AUDITED when every row was compared and validated, DRIFT when a compared row is off, NOT AUDITED when rows could not be compared and none drifted. */
43
+ export function auditVerdict(document) {
44
+ if (document.ok) return AUDIT_VERDICTS.validated
45
+ return document.counts.drift.value > 0 ? AUDIT_VERDICTS.drift : AUDIT_VERDICTS.unavailable
46
+ }
47
+
19
48
  /** A terminal report made only from the shared style vocabulary. */
20
49
  export function renderAudit(document, { colour = colourEnabled() } = {}) {
21
50
  const c = styler(colour)
@@ -35,13 +64,10 @@ export function renderAudit(document, { colour = colourEnabled() } = {}) {
35
64
  out.push(`${mark(markFor(row), c)}${c("name", row.id)}`)
36
65
  out.push(...wrap(detail, { indent: GUTTER }, c))
37
66
  if ((row.state === "ahead" || row.state === "diverged") && validated) {
38
- out.push(...action(`git -C ${JSON.stringify(row.sourceDir)} checkout ${validated}`, c))
67
+ out.push(...action(`git -C ${shellPath(row.sourceDir)} checkout ${validated}`, c))
39
68
  }
40
69
  out.push("")
41
70
  }
42
- const total = document.counts.audited.value
43
- const good = document.counts.validated.value
44
- const drift = document.counts.drift.value
45
71
  if (document.updateRoute) {
46
72
  out.push(...action(`To validate a newer commit: ${document.updateRoute.url}`, c))
47
73
  out.push(...wrap(`${document.updateRoute.name}; choose ${JSON.stringify(document.updateRoute.choice)}.`, { indent: GUTTER }, c))
@@ -51,6 +77,6 @@ export function renderAudit(document, { colour = colourEnabled() } = {}) {
51
77
  out.push(...wrap(`Verification route unavailable: ${document.updateRouteError}; run omakit pin.`, { indent: GUTTER }, c))
52
78
  out.push("")
53
79
  }
54
- out.push(...verdict(document.ok ? "pass" : "fail", document.ok ? AUDIT_VERDICTS.validated : AUDIT_VERDICTS.drift, `${good} of ${total} run a commit the marketplace validated; ${drift} run one it never saw.`, c))
80
+ out.push(...verdict(document.ok ? "pass" : "fail", auditVerdict(document), auditSummary(document), c))
55
81
  return out.join("\n")
56
82
  }
@@ -0,0 +1,138 @@
1
+ // `omakit add <block> [plugin-dir] [--update]`: copy a shipped block's
2
+ // files into <plugin-dir>/omakit/ and write omakit/NOTICE, and nothing
3
+ // else. This is the one code path in omakit that writes into a plugin
4
+ // tree, so it is held tighter than the rest (tests/unit/self-containment
5
+ // .test.mjs and tests/unit/blocks.test.mjs): the file names come from the
6
+ // block registry and are checked against the agent-control list before a
7
+ // byte is written; an existing file is never overwritten without --update;
8
+ // with --update a copy whose body is not one omakit shipped is refused,
9
+ // before anything is written, because a modified block is the author's;
10
+ // and every decision is made over every file first, so a refusal leaves
11
+ // the directory as it was.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
14
+ import { execFileSync } from "node:child_process"
15
+ import { join, resolve } from "node:path"
16
+ import { findAgentControl } from "../marketplace/agent-control.mjs"
17
+ import { blockClosure, bodySha256, parseHeader, renderNotice, shippedBlock, shippedBlocks, shippedHistory, withBodySha256, withSourceCommit } from "./registry.mjs"
18
+ import { recordedCommit } from "./record-commit.mjs"
19
+
20
+ export class AddError extends Error {
21
+ constructor(code, message, remedy = null) {
22
+ super(message)
23
+ this.name = "AddError"
24
+ this.code = code
25
+ this.remedy = remedy
26
+ }
27
+ }
28
+
29
+ /** The directory a block lands in, inside the plugin. */
30
+ export const BLOCK_DIR = "omakit"
31
+
32
+ /**
33
+ * The omakit commit the files come from: the checkout's HEAD when omakit
34
+ * runs from a Git checkout, else the commit the release workflow recorded
35
+ * in tools/blocks/commit.json before it packed (record-commit.mjs), else
36
+ * the `gitHead` npm records at publish, else null. Null is a refusal in
37
+ * `add`, never a blank in a header: a header that cannot name its commit
38
+ * is a bug (docs/evidence/ux/2026-09-19-first-user-test.json, finding 2).
39
+ */
40
+ export function sourceCommit(repoRoot) {
41
+ if (existsSync(join(repoRoot, ".git"))) {
42
+ try {
43
+ return execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()
44
+ } catch {
45
+ // fall through to the package's record
46
+ }
47
+ }
48
+ const recorded = recordedCommit(repoRoot)
49
+ if (recorded) return recorded
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
52
+ if (/^[0-9a-f]{40}$/.test(String(pkg.gitHead || ""))) return pkg.gitHead
53
+ } catch {
54
+ // no package.json to read
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** The text `add` writes for one shipped file: the header stamped with the commit, the body untouched. */
60
+ export function stampedText(entry, commit) {
61
+ return withSourceCommit(withBodySha256(entry.text, entry.sha256), commit)
62
+ }
63
+
64
+ /**
65
+ * What would happen to one file: `write` when it is not there, `current`
66
+ * when it is there with the shipped body, `update` when it is there with
67
+ * a body omakit shipped before (any version) and --update was passed, and
68
+ * a refusal otherwise.
69
+ */
70
+ function decide(target, entry, { update }) {
71
+ if (!existsSync(target)) return { state: "write" }
72
+ if (!statSync(target).isFile()) throw new AddError("not-a-file", `${target} exists and is not a regular file`, "Move it out of the way; omakit does not replace directories or links.")
73
+ const existing = readFileSync(target, "utf8")
74
+ const parsed = parseHeader(existing)
75
+ if (parsed && bodySha256(parsed.body) === entry.sha256) {
76
+ if (parsed.version === entry.version) return { state: "current", version: parsed.version }
77
+ // The shipped body under an older header: a version bump that did not
78
+ // touch this file (measured on 0.2.1: Run.qml's body was 0.2.0's, and
79
+ // `--update` left its header at 0.2.0 beside a 0.2.1 supervisor, so
80
+ // inspect read one block as two versions). The header moves with --update.
81
+ if (!update) throw new AddError("exists", `${target} has the shipped body under a ${parsed.version} header; omakit ships ${entry.version}, and --update moves the header`, "omakit add <block> [plugin-dir] --update")
82
+ return { state: "update", version: parsed.version }
83
+ }
84
+ if (!update) throw new AddError("exists", `${target} is already there; pass --update to replace an unmodified copy`, "omakit add <block> [plugin-dir] --update")
85
+ if (!parsed) throw new AddError("modified", `${target} is not an omakit block file (no block header), so it is the plugin's own and is not replaced`, "Move or rename the file, then run add again.")
86
+ const older = shippedHistory().find((row) => row.block === entry.block && row.file === entry.file && row.sha256 === bodySha256(parsed.body))
87
+ if (!older) throw new AddError("modified", `${target} carries a block header but its body is not one omakit shipped, so it was modified and is the plugin's own; it is not replaced`, "Keep your copy, or move it aside and run add again; omakit/NOTICE is where modifications are listed.")
88
+ return { state: "update", version: older.version }
89
+ }
90
+
91
+ /**
92
+ * @param {{ repoRoot: string, block: string, dir?: string, update?: boolean, cwd?: string }} options
93
+ * @returns {{ block: string, version: string, dir: string, commit: string, files: Array<{ path: string, state: "written"|"updated"|"current", sha256: string, from: string|null }>, notice: { path: string, state: "written"|"updated"|"current" } }}
94
+ */
95
+ export function addBlock({ repoRoot, block, dir = ".", update = false, cwd = process.cwd() }) {
96
+ const shipped = shippedBlock(block)
97
+ if (!shipped) throw new AddError("unknown-block", `${block} is not a block omakit ships; it ships ${shippedBlocks().map((entry) => entry.name).join(", ")}`, "omakit add run [plugin-dir], or omakit add store [plugin-dir]")
98
+ // A block that uses another one (store uses run) brings it along: the
99
+ // required block's files first, then its own, one decision list.
100
+ const closure = blockClosure(block).map((name) => shippedBlock(name))
101
+ const pluginDir = resolve(cwd, dir)
102
+ if (!existsSync(pluginDir) || !statSync(pluginDir).isDirectory()) throw new AddError("plugin-dir-not-found", `${pluginDir} is not a directory`, "Pass the plugin's directory, the one with its manifest.json.")
103
+ if (!existsSync(join(pluginDir, "manifest.json"))) throw new AddError("not-a-plugin", `${pluginDir} has no manifest.json, so it is not a plugin directory`, "Pass the plugin's directory, the one with its manifest.json.")
104
+ // The names that will be written, checked against the agent-control list
105
+ // before any decision: a block can never carry an instruction file along.
106
+ const control = findAgentControl([...closure.flatMap((entry) => entry.files), { file: "NOTICE" }].map((entry) => ({ path: `${BLOCK_DIR}/${entry.file}`, type: "blob" })))
107
+ if (control.length) throw new AddError("agent-control", `${control.map((entry) => entry.path).join(", ")}: an agent-control file is never written into a plugin`)
108
+ const blockDir = join(pluginDir, BLOCK_DIR)
109
+ // Every decision first; the first refusal stops everything, unwritten.
110
+ const decisions = closure.flatMap((one) => one.files.map((entry) => ({ entry: { ...entry, block: one.name }, target: join(blockDir, entry.file), ...decide(join(blockDir, entry.file), { ...entry, block: one.name }, { update }) })))
111
+ const commit = sourceCommit(repoRoot)
112
+ if (!commit) throw new AddError("no-source-commit", `this omakit names no source commit: it runs from neither a Git checkout nor a package the release workflow stamped (tools/blocks/commit.json is empty and package.json has no gitHead), and a block header that cannot name the commit it came from is not written`, "Install omakit from the npm registry (`npm i -g omakit`), or run it from a checkout of the repository.")
113
+ mkdirSync(blockDir, { recursive: true })
114
+ const files = []
115
+ for (const { entry, target, state, version } of decisions) {
116
+ if (state !== "current") {
117
+ const blockFile = target
118
+ writeFileSync(blockFile, stampedText(entry, commit))
119
+ }
120
+ files.push({ path: join(BLOCK_DIR, entry.file), block: entry.block, state: state === "write" ? "written" : state === "update" ? "updated" : "current", sha256: entry.sha256, from: version && version !== shippedBlock(entry.block).version ? version : null })
121
+ }
122
+ // NOTICE lists every block present in omakit/ after the write, recognised
123
+ // by the headers of the files that are there.
124
+ const present = shippedBlocks().filter((candidate) => candidate.files.every((entry) => existsSync(join(blockDir, entry.file)) && parseHeader(readFileSync(join(blockDir, entry.file), "utf8"))?.name === candidate.name))
125
+ const noticeText = renderNotice(present, commit)
126
+ const noticePath = join(blockDir, "NOTICE")
127
+ const noticeBefore = existsSync(noticePath) ? readFileSync(noticePath, "utf8") : null
128
+ let noticeState = "current"
129
+ // A NOTICE already there stays when nothing was written: the commit it
130
+ // names is the one the files came from, not the one add ran at.
131
+ const untouched = files.every((entry) => entry.state === "current") && noticeBefore !== null
132
+ if (!untouched && noticeBefore !== noticeText) {
133
+ const blockFile = noticePath
134
+ writeFileSync(blockFile, noticeText)
135
+ noticeState = noticeBefore === null ? "written" : "updated"
136
+ }
137
+ return { block: shipped.name, version: shipped.version, requires: closure.filter((one) => one.name !== shipped.name).map((one) => `${one.name} ${one.version}`), dir: pluginDir, commit, files, notice: { path: join(BLOCK_DIR, "NOTICE"), state: noticeState } }
138
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "commit": "d3bd117d9fa0650c8dd5968fa5c94f7369162d40",
3
+ "recordedAt": "2026-09-19T16:44:26.025Z",
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
+ }
@@ -0,0 +1,77 @@
1
+ // Release tool: write the checkout's HEAD into tools/blocks/commit.json so
2
+ // the packaged omakit names the commit its block files come from. Run by
3
+ // .github/workflows/release.yml before `npm pack`, never by a user, and
4
+ // never at install time (there is no lifecycle hook; tests/unit/
5
+ // self-containment.test.mjs holds package.json to none). Measured on
6
+ // 2026-09-19 by a first user of the packaged product: every stamped
7
+ // header, the NOTICE and `add`'s output said `commit unknown`, because
8
+ // the package has no .git and `npm pack` records no gitHead in the
9
+ // tarball's package.json (docs/evidence/ux/2026-09-19-first-user-test.json,
10
+ // finding 2).
11
+ //
12
+ // node tools/blocks/record-commit.mjs # writes the checkout's HEAD
13
+ // node tools/blocks/record-commit.mjs --check # exits 1 when the file names a commit other than HEAD, or none
14
+ // node tools/blocks/record-commit.mjs --clear # puts the checkout's null back, after a local `npm run pack:release`
15
+ //
16
+ // `npm run pack:release` is the release step run by hand: record, check,
17
+ // pack, clear, so the tarball a person tests is the tarball the workflow
18
+ // publishes, and the checkout is left as it was. A raw `npm pack` names no
19
+ // commit, and the package it makes refuses `add` (measured on 2026-09-19 by
20
+ // an acceptance tester whose candidate said no-source-commit; docs/evidence/
21
+ // ux/2026-09-19-acceptance.json, finding 7).
22
+
23
+ import { execFileSync } from "node:child_process"
24
+ import { readFileSync, writeFileSync } from "node:fs"
25
+ import { dirname, join, resolve } from "node:path"
26
+ import { fileURLToPath, pathToFileURL } from "node:url"
27
+
28
+ export const COMMIT_FILE = "tools/blocks/commit.json"
29
+
30
+ /** The recorded commit in a tree, or null: the file's `commit` when it is a 40-character sha. */
31
+ export function recordedCommit(root) {
32
+ try {
33
+ const record = JSON.parse(readFileSync(join(root, COMMIT_FILE), "utf8"))
34
+ return /^[0-9a-f]{40}$/.test(String(record.commit || "")) ? record.commit : null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** The one write: the record with `commit` and `recordedAt` set, the file's own explanation kept. */
41
+ function writeRecord(root, commit, recordedAt) {
42
+ const file = join(root, COMMIT_FILE)
43
+ const record = JSON.parse(readFileSync(file, "utf8"))
44
+ const commitFile = file
45
+ writeFileSync(commitFile, `${JSON.stringify({ ...record, commit, recordedAt }, null, 2)}\n`)
46
+ }
47
+
48
+ /** Write `commit` into the tree's record, keeping the file's own explanation. */
49
+ export function recordCommit(root, commit, { now = new Date() } = {}) {
50
+ if (!/^[0-9a-f]{40}$/.test(String(commit || ""))) throw new Error(`record-commit: not a 40-character commit: ${commit}`)
51
+ writeRecord(root, commit, now.toISOString())
52
+ return commit
53
+ }
54
+
55
+ /** Put the checkout's null back: what a checkout carries, where git is the source. */
56
+ export function clearCommit(root) {
57
+ writeRecord(root, null, null)
58
+ }
59
+
60
+ const invoked = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
61
+ if (invoked) {
62
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
63
+ const head = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
64
+ if (process.argv.includes("--check")) {
65
+ const recorded = recordedCommit(root)
66
+ if (recorded !== head) {
67
+ process.stderr.write(`${COMMIT_FILE} names ${recorded || "no commit"}; HEAD is ${head}\n`)
68
+ process.exit(1)
69
+ }
70
+ process.stdout.write(`${COMMIT_FILE} names HEAD ${head}\n`)
71
+ } else if (process.argv.includes("--clear")) {
72
+ clearCommit(root)
73
+ process.stdout.write(`${COMMIT_FILE} is null again; git is the source in a checkout\n`)
74
+ } else {
75
+ process.stdout.write(`recorded ${recordCommit(root, head)} in ${COMMIT_FILE}\n`)
76
+ }
77
+ }