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.
- package/README.md +39 -32
- 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 +7 -4
- 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 +37 -6
- package/tools/inspect/functions.mjs +236 -12
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +71 -5
- package/tools/inspect/measure-functions.mjs +12 -3
- package/tools/inspect/patterns.mjs +35 -18
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +33 -6
- 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
|
@@ -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"
|
package/tests/parity/corpus.mjs
CHANGED
|
@@ -34,9 +34,14 @@ function registrySources(pinDir) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function strataSizes(count) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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) {
|
package/tests/parity/run.mjs
CHANGED
|
@@ -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}` }
|
package/tools/audit/audit.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/tools/audit/git.mjs
CHANGED
|
@@ -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"],
|
package/tools/audit/report.mjs
CHANGED
|
@@ -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 ${
|
|
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
|
|
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
|
+
}
|