mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 16:48:54 +01:00
Split the two shell scripts and the gate's own config
aur-publish/publish.sh 439 -> 148 + lib/aur_registration 107
+ lib/aur_verify 122 + lib/aur_publish_one 107
guard-lib/lib/file_guard.sh 350 -> 140 + file_guard_unlock 214
file_length/config.py 252 -> 45 + _tables 202 + exemptions 77
The libs are sourced, never executed: they read the readonly config the entry
script declares, and a `readonly` re-run inside a twice-sourced lib aborts
under `set -e`. Verified by function parity rather than by eye — publish.sh
resolves the same 17 functions as before the split, guardctl the same 15 —
plus `shellcheck -x` clean on every file and a real `--help` / bad-option run.
file_length is the gate's own code, so a broken import there turns the gate
into a no-op. Split three ways to avoid an import cycle without a `# noqa`:
_tables holds the data, exemptions the rules that read it, config re-exports
both so `from file_length.config import is_generated` still resolves. The
first attempt was wrong — EXCLUDED_DIRS was left behind and every invocation
raised — caught because the gate was re-run in both modes rather than
assumed. Now verified: exit 0 on a clean file, exit 1 on an over-cap one,
--explain intact, and the same 61 exemption decisions as HEAD.
Also deletes baseline-2026-08-14.json and SURVEY-2026-08-14.txt: the task
forbids a baseline file, and nothing in the tree read either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
parent
200432a2b4
commit
085d08b5c9
107
aur-publish/lib/aur_publish_one.sh
Normal file
107
aur-publish/lib/aur_publish_one.sh
Normal file
@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Building, verifying and pushing one package to the AUR.
|
||||
#
|
||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
||||
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
|
||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
publish_one() {
|
||||
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
|
||||
local dir="$AUR_ROOT/$pkg"
|
||||
local version
|
||||
|
||||
log "$pkg"
|
||||
|
||||
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
|
||||
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
|
||||
|
||||
version="$(newest_tag "$repo")"
|
||||
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
|
||||
info "newest release tag: v$version"
|
||||
|
||||
cd "$dir"
|
||||
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
|
||||
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
|
||||
# computed after the tag exists — never committed ahead of time.
|
||||
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
|
||||
|
||||
info "building"
|
||||
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
|
||||
warn "$pkg: makepkg failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local built
|
||||
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
|
||||
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
|
||||
|
||||
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
|
||||
# for /opt-installed apps, so only genuinely new errors are worth failing on.
|
||||
local findings
|
||||
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
|
||||
if [[ -n "$findings" ]]; then
|
||||
warn "$pkg: namcap errors:"
|
||||
printf ' %s\n' "$findings"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "installing and launching"
|
||||
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|
||||
|| { warn "$pkg: pacman -U failed"; return 1; }
|
||||
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
|
||||
|
||||
# Generated before the rule check, which verifies it matches the PKGBUILD.
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
check_aur_rules "$pkg" || return 1
|
||||
|
||||
# The AUR repo is a separate remote living alongside the build dir; adding
|
||||
# it here (rather than cloning into a fresh path) keeps the verified
|
||||
# PKGBUILD and the thing being pushed byte-identical.
|
||||
if [[ ! -d .git ]]; then
|
||||
git init -q -b master .
|
||||
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
||||
fi
|
||||
git remote get-url origin >/dev/null 2>&1 \
|
||||
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
||||
|
||||
# Fetch first: an existing AUR package must be built on, not overwritten.
|
||||
if git fetch -q origin master 2>/dev/null; then
|
||||
git reset -q --soft FETCH_HEAD 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat > .gitignore <<'EOF'
|
||||
# Build products; the AUR tracks only the recipe.
|
||||
*.pkg.tar.zst
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
EOF
|
||||
|
||||
git add PKGBUILD .SRCINFO .gitignore
|
||||
if git diff --cached --quiet; then
|
||||
info "already up to date on the AUR; nothing to push"
|
||||
PUBLISHED+=("$pkg v$version (unchanged)")
|
||||
return 0
|
||||
fi
|
||||
|
||||
git -c user.name='Krzysztof Rudnicki' \
|
||||
-c user.email='krzysztofrudnicki0@gmail.com' \
|
||||
commit -q -m "$pkg $version-1"
|
||||
|
||||
if ((DRY_RUN)); then
|
||||
info "DRY RUN — not pushing"
|
||||
PUBLISHED+=("$pkg v$version (dry run)")
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "pushing to the AUR"
|
||||
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
|
||||
PUBLISHED+=("$pkg v$version")
|
||||
return 0
|
||||
}
|
||||
|
||||
107
aur-publish/lib/aur_registration.sh
Normal file
107
aur-publish/lib/aur_registration.sh
Normal file
@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Waiting for AUR registration and SSH access to come up.
|
||||
#
|
||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
||||
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
|
||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
register_status() {
|
||||
python3 "$GATE" /register 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
open_browser() {
|
||||
local url="$1"
|
||||
local browser
|
||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
||||
if command -v "$browser" >/dev/null 2>&1; then
|
||||
"$browser" "$url" >/dev/null 2>&1 &
|
||||
info "Opened $url in $browser"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
warn "No browser found; open this yourself: $url"
|
||||
}
|
||||
|
||||
ensure_key() {
|
||||
if [[ ! -f "$SSH_KEY" ]]; then
|
||||
log "Generating an AUR SSH key (none at $SSH_KEY)"
|
||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
|
||||
fi
|
||||
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
|
||||
log "Adding $AUR_HOST to ~/.ssh/config"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
cat >> "$HOME/.ssh/config" <<EOF
|
||||
|
||||
# AUR uses a dedicated key so it stays separate from the GitHub identity.
|
||||
Host $AUR_HOST
|
||||
User aur
|
||||
IdentityFile $SSH_KEY
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
chmod 600 "$HOME/.ssh/config"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_works() {
|
||||
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
|
||||
# successful key auth is the success signal, not an error.
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
|
||||
grep -qiE 'interactive shell is disabled|Welcome to AUR'
|
||||
}
|
||||
|
||||
wait_for_registration() {
|
||||
local status
|
||||
status="$(register_status)"
|
||||
if [[ "$status" == "200" ]]; then
|
||||
log "AUR registration is OPEN"
|
||||
else
|
||||
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
|
||||
info "Ctrl-C to stop; re-run any time, nothing is lost."
|
||||
while :; do
|
||||
sleep "$POLL_SECONDS"
|
||||
status="$(register_status)"
|
||||
if [[ "$status" == "200" ]]; then
|
||||
log "Registration is BACK (HTTP 200)"
|
||||
break
|
||||
fi
|
||||
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
|
||||
done
|
||||
fi
|
||||
|
||||
open_browser "https://$AUR_HOST/register"
|
||||
cat <<EOF
|
||||
|
||||
------------------------------------------------------------------
|
||||
Sign up, and paste this into the "SSH Public Key" field:
|
||||
|
||||
$(sed 's/^/ /' "$SSH_KEY.pub")
|
||||
|
||||
You will need to confirm the address from your email before the
|
||||
account works. This script waits for that automatically.
|
||||
------------------------------------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
log "Waiting for the SSH key to authenticate against the AUR"
|
||||
local waited=0
|
||||
until ssh_works; do
|
||||
sleep 30
|
||||
waited=$((waited + 30))
|
||||
if ((waited % 300 == 0)); then
|
||||
info "still waiting ($((waited / 60)) min) — key not accepted yet"
|
||||
fi
|
||||
done
|
||||
log "SSH authentication OK"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: build, verify and publish one package
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
122
aur-publish/lib/aur_verify.sh
Normal file
122
aur-publish/lib/aur_verify.sh
Normal file
@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Gates a package must pass before it is allowed near the AUR.
|
||||
#
|
||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
||||
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
|
||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
newest_tag() {
|
||||
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
|
||||
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
|
||||
git -C "$1" ls-remote --tags origin 2>/dev/null |
|
||||
awk '{print $2}' |
|
||||
sed 's|refs/tags/||' |
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
|
||||
sed 's/^v//' |
|
||||
sort -V |
|
||||
tail -1
|
||||
}
|
||||
|
||||
verify_installed() {
|
||||
local binary="$1" expected="$2" wrapper="$3" output
|
||||
|
||||
# An instance left over from a previous run would make the wrapper print
|
||||
# "already running" and hand off, which passes trivially and turns the
|
||||
# strongest gate in this script into the weakest. Clear it first so the
|
||||
# check always exercises a real cold start.
|
||||
#
|
||||
# Resolved through /proc/<pid>/exe rather than any form of name or
|
||||
# command-line matching. Every argv-based approach selects the wrong
|
||||
# processes here:
|
||||
# * `pkill -x` compares against the kernel's 15-character comm field, and
|
||||
# two of these wrappers are longer (habit_stack_desktop is 19), so a
|
||||
# name match would silently never fire.
|
||||
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
|
||||
# any process whose command line merely *mentions* the path — this
|
||||
# script, the shell running it, and the matching tool itself all
|
||||
# qualify, so the run kills its own shell instead of the app. A
|
||||
# $$/$PPID guard does not cover it, because the offending shell is
|
||||
# often a more distant ancestor.
|
||||
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
|
||||
# being executed, so it cannot collide with an incidental mention.
|
||||
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
|
||||
# a glob instead of threading the package name through another parameter.
|
||||
#
|
||||
# The link is read without `-f`: it is already fully resolved, and by the
|
||||
# time this runs `pacman -U` has replaced the binary, so a leftover process
|
||||
# points at an unlinked inode that the kernel reports with a " (deleted)"
|
||||
# suffix. That suffix has to be stripped or the match silently misses the
|
||||
# one process this exists to kill.
|
||||
local pid exe killed=()
|
||||
for pid in /proc/[0-9]*; do
|
||||
pid="${pid#/proc/}"
|
||||
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
|
||||
exe="${exe% (deleted)}"
|
||||
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
|
||||
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
|
||||
done
|
||||
if ((${#killed[@]})); then
|
||||
info "stopped a running instance so the launch check is meaningful"
|
||||
# `kill` only reports that the signal was sent. Waiting for the process
|
||||
# to actually go keeps a slow exit from failing the cold start below on
|
||||
# a port that is still bound.
|
||||
local waited=0
|
||||
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
|
||||
sleep 0.1
|
||||
((waited++))
|
||||
done
|
||||
fi
|
||||
|
||||
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
|
||||
if grep -qF "$expected" <<<"$output"; then
|
||||
return 0
|
||||
fi
|
||||
warn "unexpected startup output: $output"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The AUR does not review uploads, but "packages that violate the rules may be
|
||||
# deleted without warning" (AUR submission guidelines). These are the rules that
|
||||
# are mechanically checkable, enforced here so a violation blocks the push
|
||||
# rather than surfacing later as a deletion request.
|
||||
check_aur_rules() {
|
||||
local pkg="$1" problems=()
|
||||
|
||||
grep -q '^# Maintainer:' PKGBUILD \
|
||||
|| problems+=("missing '# Maintainer:' comment on line 1")
|
||||
|
||||
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|
||||
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
|
||||
|
||||
# `replaces` makes pacman rip out the named package on the next -Sy, which
|
||||
# is only correct for a genuine rename. `conflicts` is the right tool.
|
||||
grep -q '^replaces=' PKGBUILD \
|
||||
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
|
||||
|
||||
# A package already in an official repo must never be duplicated.
|
||||
if pacman -Si "$pkg" >/dev/null 2>&1; then
|
||||
problems+=("$pkg exists in an official repo — must not be submitted")
|
||||
fi
|
||||
|
||||
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|
||||
|| problems+=("missing pkgdesc/url/license")
|
||||
|
||||
# A stale .SRCINFO is the single most common upload rejection.
|
||||
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
|
||||
problems+=(".SRCINFO does not match PKGBUILD")
|
||||
fi
|
||||
|
||||
if ((${#problems[@]})); then
|
||||
warn "$pkg: AUR rule violations:"
|
||||
printf ' - %s\n' "${problems[@]}"
|
||||
return 1
|
||||
fi
|
||||
info "AUR rule checks passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -86,305 +86,14 @@ require_tools() {
|
||||
# Phase 1: wait for registration, then for the key to work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_status() {
|
||||
python3 "$GATE" /register 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
open_browser() {
|
||||
local url="$1"
|
||||
local browser
|
||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
||||
if command -v "$browser" >/dev/null 2>&1; then
|
||||
"$browser" "$url" >/dev/null 2>&1 &
|
||||
info "Opened $url in $browser"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
warn "No browser found; open this yourself: $url"
|
||||
}
|
||||
|
||||
ensure_key() {
|
||||
if [[ ! -f "$SSH_KEY" ]]; then
|
||||
log "Generating an AUR SSH key (none at $SSH_KEY)"
|
||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
|
||||
fi
|
||||
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
|
||||
log "Adding $AUR_HOST to ~/.ssh/config"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
cat >> "$HOME/.ssh/config" <<EOF
|
||||
|
||||
# AUR uses a dedicated key so it stays separate from the GitHub identity.
|
||||
Host $AUR_HOST
|
||||
User aur
|
||||
IdentityFile $SSH_KEY
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
chmod 600 "$HOME/.ssh/config"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_works() {
|
||||
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
|
||||
# successful key auth is the success signal, not an error.
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
|
||||
grep -qiE 'interactive shell is disabled|Welcome to AUR'
|
||||
}
|
||||
|
||||
wait_for_registration() {
|
||||
local status
|
||||
status="$(register_status)"
|
||||
if [[ "$status" == "200" ]]; then
|
||||
log "AUR registration is OPEN"
|
||||
else
|
||||
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
|
||||
info "Ctrl-C to stop; re-run any time, nothing is lost."
|
||||
while :; do
|
||||
sleep "$POLL_SECONDS"
|
||||
status="$(register_status)"
|
||||
if [[ "$status" == "200" ]]; then
|
||||
log "Registration is BACK (HTTP 200)"
|
||||
break
|
||||
fi
|
||||
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
|
||||
done
|
||||
fi
|
||||
|
||||
open_browser "https://$AUR_HOST/register"
|
||||
cat <<EOF
|
||||
|
||||
------------------------------------------------------------------
|
||||
Sign up, and paste this into the "SSH Public Key" field:
|
||||
|
||||
$(sed 's/^/ /' "$SSH_KEY.pub")
|
||||
|
||||
You will need to confirm the address from your email before the
|
||||
account works. This script waits for that automatically.
|
||||
------------------------------------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
log "Waiting for the SSH key to authenticate against the AUR"
|
||||
local waited=0
|
||||
until ssh_works; do
|
||||
sleep 30
|
||||
waited=$((waited + 30))
|
||||
if ((waited % 300 == 0)); then
|
||||
info "still waiting ($((waited / 60)) min) — key not accepted yet"
|
||||
fi
|
||||
done
|
||||
log "SSH authentication OK"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: build, verify and publish one package
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
newest_tag() {
|
||||
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
|
||||
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
|
||||
git -C "$1" ls-remote --tags origin 2>/dev/null |
|
||||
awk '{print $2}' |
|
||||
sed 's|refs/tags/||' |
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
|
||||
sed 's/^v//' |
|
||||
sort -V |
|
||||
tail -1
|
||||
}
|
||||
|
||||
verify_installed() {
|
||||
local binary="$1" expected="$2" wrapper="$3" output
|
||||
|
||||
# An instance left over from a previous run would make the wrapper print
|
||||
# "already running" and hand off, which passes trivially and turns the
|
||||
# strongest gate in this script into the weakest. Clear it first so the
|
||||
# check always exercises a real cold start.
|
||||
#
|
||||
# Resolved through /proc/<pid>/exe rather than any form of name or
|
||||
# command-line matching. Every argv-based approach selects the wrong
|
||||
# processes here:
|
||||
# * `pkill -x` compares against the kernel's 15-character comm field, and
|
||||
# two of these wrappers are longer (habit_stack_desktop is 19), so a
|
||||
# name match would silently never fire.
|
||||
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
|
||||
# any process whose command line merely *mentions* the path — this
|
||||
# script, the shell running it, and the matching tool itself all
|
||||
# qualify, so the run kills its own shell instead of the app. A
|
||||
# $$/$PPID guard does not cover it, because the offending shell is
|
||||
# often a more distant ancestor.
|
||||
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
|
||||
# being executed, so it cannot collide with an incidental mention.
|
||||
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
|
||||
# a glob instead of threading the package name through another parameter.
|
||||
#
|
||||
# The link is read without `-f`: it is already fully resolved, and by the
|
||||
# time this runs `pacman -U` has replaced the binary, so a leftover process
|
||||
# points at an unlinked inode that the kernel reports with a " (deleted)"
|
||||
# suffix. That suffix has to be stripped or the match silently misses the
|
||||
# one process this exists to kill.
|
||||
local pid exe killed=()
|
||||
for pid in /proc/[0-9]*; do
|
||||
pid="${pid#/proc/}"
|
||||
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
|
||||
exe="${exe% (deleted)}"
|
||||
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
|
||||
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
|
||||
done
|
||||
if ((${#killed[@]})); then
|
||||
info "stopped a running instance so the launch check is meaningful"
|
||||
# `kill` only reports that the signal was sent. Waiting for the process
|
||||
# to actually go keeps a slow exit from failing the cold start below on
|
||||
# a port that is still bound.
|
||||
local waited=0
|
||||
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
|
||||
sleep 0.1
|
||||
((waited++))
|
||||
done
|
||||
fi
|
||||
|
||||
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
|
||||
if grep -qF "$expected" <<<"$output"; then
|
||||
return 0
|
||||
fi
|
||||
warn "unexpected startup output: $output"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The AUR does not review uploads, but "packages that violate the rules may be
|
||||
# deleted without warning" (AUR submission guidelines). These are the rules that
|
||||
# are mechanically checkable, enforced here so a violation blocks the push
|
||||
# rather than surfacing later as a deletion request.
|
||||
check_aur_rules() {
|
||||
local pkg="$1" problems=()
|
||||
|
||||
grep -q '^# Maintainer:' PKGBUILD \
|
||||
|| problems+=("missing '# Maintainer:' comment on line 1")
|
||||
|
||||
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|
||||
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
|
||||
|
||||
# `replaces` makes pacman rip out the named package on the next -Sy, which
|
||||
# is only correct for a genuine rename. `conflicts` is the right tool.
|
||||
grep -q '^replaces=' PKGBUILD \
|
||||
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
|
||||
|
||||
# A package already in an official repo must never be duplicated.
|
||||
if pacman -Si "$pkg" >/dev/null 2>&1; then
|
||||
problems+=("$pkg exists in an official repo — must not be submitted")
|
||||
fi
|
||||
|
||||
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|
||||
|| problems+=("missing pkgdesc/url/license")
|
||||
|
||||
# A stale .SRCINFO is the single most common upload rejection.
|
||||
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
|
||||
problems+=(".SRCINFO does not match PKGBUILD")
|
||||
fi
|
||||
|
||||
if ((${#problems[@]})); then
|
||||
warn "$pkg: AUR rule violations:"
|
||||
printf ' - %s\n' "${problems[@]}"
|
||||
return 1
|
||||
fi
|
||||
info "AUR rule checks passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
publish_one() {
|
||||
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
|
||||
local dir="$AUR_ROOT/$pkg"
|
||||
local version
|
||||
|
||||
log "$pkg"
|
||||
|
||||
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
|
||||
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
|
||||
|
||||
version="$(newest_tag "$repo")"
|
||||
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
|
||||
info "newest release tag: v$version"
|
||||
|
||||
cd "$dir"
|
||||
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
|
||||
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
|
||||
# computed after the tag exists — never committed ahead of time.
|
||||
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
|
||||
|
||||
info "building"
|
||||
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
|
||||
warn "$pkg: makepkg failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local built
|
||||
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
|
||||
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
|
||||
|
||||
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
|
||||
# for /opt-installed apps, so only genuinely new errors are worth failing on.
|
||||
local findings
|
||||
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
|
||||
if [[ -n "$findings" ]]; then
|
||||
warn "$pkg: namcap errors:"
|
||||
printf ' %s\n' "$findings"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "installing and launching"
|
||||
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|
||||
|| { warn "$pkg: pacman -U failed"; return 1; }
|
||||
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
|
||||
|
||||
# Generated before the rule check, which verifies it matches the PKGBUILD.
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
check_aur_rules "$pkg" || return 1
|
||||
|
||||
# The AUR repo is a separate remote living alongside the build dir; adding
|
||||
# it here (rather than cloning into a fresh path) keeps the verified
|
||||
# PKGBUILD and the thing being pushed byte-identical.
|
||||
if [[ ! -d .git ]]; then
|
||||
git init -q -b master .
|
||||
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
||||
fi
|
||||
git remote get-url origin >/dev/null 2>&1 \
|
||||
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
||||
|
||||
# Fetch first: an existing AUR package must be built on, not overwritten.
|
||||
if git fetch -q origin master 2>/dev/null; then
|
||||
git reset -q --soft FETCH_HEAD 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat > .gitignore <<'EOF'
|
||||
# Build products; the AUR tracks only the recipe.
|
||||
*.pkg.tar.zst
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
EOF
|
||||
|
||||
git add PKGBUILD .SRCINFO .gitignore
|
||||
if git diff --cached --quiet; then
|
||||
info "already up to date on the AUR; nothing to push"
|
||||
PUBLISHED+=("$pkg v$version (unchanged)")
|
||||
return 0
|
||||
fi
|
||||
|
||||
git -c user.name='Krzysztof Rudnicki' \
|
||||
-c user.email='krzysztofrudnicki0@gmail.com' \
|
||||
commit -q -m "$pkg $version-1"
|
||||
|
||||
if ((DRY_RUN)); then
|
||||
info "DRY RUN — not pushing"
|
||||
PUBLISHED+=("$pkg v$version (dry run)")
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "pushing to the AUR"
|
||||
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
|
||||
PUBLISHED+=("$pkg v$version")
|
||||
return 0
|
||||
}
|
||||
# The rest lives in lib/, sourced (not executed) so every function sees the
|
||||
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
|
||||
# shellcheck source=lib/aur_registration.sh
|
||||
source "$SCRIPT_DIR/lib/aur_registration.sh"
|
||||
# shellcheck source=lib/aur_verify.sh
|
||||
source "$SCRIPT_DIR/lib/aur_verify.sh"
|
||||
# shellcheck source=lib/aur_publish_one.sh
|
||||
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
|
||||
|
||||
main() {
|
||||
require_tools
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
==============================================================================
|
||||
TOTALS: 4045 readable files (code 2700, prose 580, data 765)
|
||||
code lines total: 471,749
|
||||
==============================================================================
|
||||
|
||||
### LONGEST FILES OVERALL (any readable kind), top 30
|
||||
|
||||
lines kind lang path
|
||||
------ ----- ---------- ---------------------------------------------------------------------------------------------------------
|
||||
174800 prose Text testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/words.txt
|
||||
2053 prose Markdown testsAndMisc/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md
|
||||
1838 prose Markdown todo/BACKLOG.md
|
||||
1734 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
|
||||
1570 data JSON testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/leechblock_defaults.json
|
||||
1475 data JSON macro-cam/assets/testdata/meals.json
|
||||
1437 code TypeScript konbini-67/src/core/shift.test.ts
|
||||
1417 code Dart epopeja_karta/lib/l10n/generated/app_localizations.dart
|
||||
1383 code Shell testsAndMisc/linux_configuration/scripts/single_use/features/setup_nextcloud_raspberry.sh
|
||||
1337 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
|
||||
1325 code Dart home_inventory/test/data/item_repository_test.dart
|
||||
1276 code Shell testsAndMisc/linux_configuration/scripts/single_use/features/raspberry_pi_nextcloud.sh
|
||||
1274 data JSON macro-cam/assets/testdata/model_areas.json
|
||||
1120 code Dart home_inventory/lib/data/item_repository.dart
|
||||
1116 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/root_bl9000.sh
|
||||
1101 prose Markdown diet-guard/CLAUDE.md
|
||||
1097 code TypeScript konbini-67/src/core/shift.ts
|
||||
1091 data JSON macro-cam/assets/testdata/claude_meal_estimates.json
|
||||
1091 code Shell testsAndMisc/phone_focus_mode/focus_ctl.sh
|
||||
1089 code Dart dufs-cloud/app/test/browser_screen_test.dart
|
||||
1076 code Python steam-backlog-enforcer/steam_backlog_enforcer/main.py
|
||||
1072 code Python steam-backlog-enforcer/steam_backlog_enforcer/tests/test_stats.py
|
||||
1052 code Dart diet-guard/app/lib/screens/history_screen.dart
|
||||
1047 code TypeScript dufs-cloud/web/src/components/gallery.test.tsx
|
||||
1042 code Python steam-backlog-enforcer/steam_backlog_enforcer/tests/test_total_block.py
|
||||
1017 code Dart diet-guard/app/test/screens/history_screen_test.dart
|
||||
1017 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/generate_study_materials.sh
|
||||
942 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/lookup_docs.sh
|
||||
929 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh
|
||||
925 prose Markdown todo/CLAUDE.md
|
||||
|
||||
### LONGEST CODE FILES, top 30
|
||||
|
||||
lines lang commits/yr path
|
||||
----- ---------- ---------- ---------------------------------------------------------------------------------------------------------
|
||||
1734 Shell 9 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
|
||||
1437 TypeScript 12 konbini-67/src/core/shift.test.ts
|
||||
1417 Dart 2 epopeja_karta/lib/l10n/generated/app_localizations.dart
|
||||
1383 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/features/setup_nextcloud_raspberry.sh
|
||||
1337 Shell 8 testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
|
||||
1325 Dart 6 home_inventory/test/data/item_repository_test.dart
|
||||
1276 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/features/raspberry_pi_nextcloud.sh
|
||||
1120 Dart 7 home_inventory/lib/data/item_repository.dart
|
||||
1116 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/utils/root_bl9000.sh
|
||||
1097 TypeScript 13 konbini-67/src/core/shift.ts
|
||||
1091 Shell 9 testsAndMisc/phone_focus_mode/focus_ctl.sh
|
||||
1089 Dart 12 dufs-cloud/app/test/browser_screen_test.dart
|
||||
1076 Python 26 steam-backlog-enforcer/steam_backlog_enforcer/main.py
|
||||
1072 Python 4 steam-backlog-enforcer/steam_backlog_enforcer/tests/test_stats.py
|
||||
1052 Dart 6 diet-guard/app/lib/screens/history_screen.dart
|
||||
1047 TypeScript 9 dufs-cloud/web/src/components/gallery.test.tsx
|
||||
1042 Python 2 steam-backlog-enforcer/steam_backlog_enforcer/tests/test_total_block.py
|
||||
1017 Dart 6 diet-guard/app/test/screens/history_screen_test.dart
|
||||
1017 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/generate_study_materials.sh
|
||||
942 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/lookup_docs.sh
|
||||
929 Shell 6 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh
|
||||
918 Shell 5 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh
|
||||
912 Shell 5 testsAndMisc/linux_configuration/scripts/periodic_background/hosts/install.sh
|
||||
912 Shell 3 testsAndMisc/linux_configuration/scripts/single_use/features/setup_searxng.sh
|
||||
909 Dart 15 dufs-cloud/app/lib/screens/browser_screen.dart
|
||||
897 Dart 8 screen-locker/stronglift_replacement/workout_app/lib/screens/history_screen.dart
|
||||
896 Dart 2 epopeja_karta/test/widget_test.dart
|
||||
870 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/update_android_hosts.sh
|
||||
869 Shell 3 testsAndMisc/linux_configuration/scripts/single_use/utils/analyze_repo.sh
|
||||
850 Python 3 europe-county-map/scripts/extract_ck3_counties.py
|
||||
|
||||
### LONGEST PROSE (md/txt/rst/tex), top 30
|
||||
|
||||
lines lang path
|
||||
------ -------- -----------------------------------------------------------------------------------------------
|
||||
174800 Text testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/words.txt
|
||||
2053 Markdown testsAndMisc/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md
|
||||
1838 Markdown todo/BACKLOG.md
|
||||
1101 Markdown diet-guard/CLAUDE.md
|
||||
925 Markdown todo/CLAUDE.md
|
||||
761 Markdown testsAndMisc/linux_configuration/docs/SECURITY_HARDENING_ANALYSIS.md
|
||||
709 Markdown testsAndMisc/docs/superpowers/specs/2026-05-01-phone-focus-recovery-design.md
|
||||
648 Markdown .claude/plans/i-dont-want-to-resilient-teapot.md
|
||||
621 Markdown konbini-67/SPEC-physicality.md
|
||||
592 Markdown .claude/plans/i-added-google-anonymous-elegant-stallman.md
|
||||
578 Markdown .claude/plans/i-am-trying-to-starry-emerson.md
|
||||
564 Markdown .claude/plans/inventory-app-improvements-1-cheeky-mountain.md
|
||||
548 LaTeX CV/magisterskie/cv.tex
|
||||
528 Markdown diet-guard/app/.agents/skills/dart-migrate-to-checks-package/SKILL.md
|
||||
528 Markdown todo/.agents/skills/dart-migrate-to-checks-package/SKILL.md
|
||||
521 Markdown .claude/plans/build-prompt-melodic-mango.md
|
||||
478 Markdown .claude/plans/abundant-cooking-hippo.md
|
||||
461 Markdown .claude/plans/where-the-guard-lives-fluffy-shell.md
|
||||
459 Markdown .claude/plans/changes-to-diet-guard-splendid-spindle.md
|
||||
440 Markdown .claude/plans/japanese-convenience-store-clerk-witty-cocoa.md
|
||||
421 Markdown .claude/plans/all-repos-that-build-cozy-pike.md
|
||||
419 Markdown diet-guard/app/.agents/skills/dart-setup-ffi-assets/SKILL.md
|
||||
419 Markdown todo/.agents/skills/dart-setup-ffi-assets/SKILL.md
|
||||
406 Markdown .claude/plans/grind-leetcode-guard-velvet-engelbart.md
|
||||
402 Markdown .claude/skills/wiki-to-mcp-kb/references/pitfalls.md
|
||||
402 Markdown .claude/plans/inventory-management-for-spicy-cray.md
|
||||
390 Markdown .claude/plans/read-and-fix-incident-2026-08-05-no-way-wiggly-karp.md
|
||||
386 Markdown .claude/plans/take-https-github-com-punkpeye-awesome-m-gentle-swing.md
|
||||
384 Markdown testsAndMisc/.claude/skills/test-driven-development/SKILL.md
|
||||
384 Markdown testsAndMisc/.github/skills/test-driven-development/SKILL.md
|
||||
|
||||
### TOP REFACTOR ROI (lines x commits-last-year), top 30
|
||||
|
||||
roi lines commits/yr lang path
|
||||
----- ----- ---------- ---------- ---------------------------------------------------------------------------------------------------------
|
||||
27976 1076 26 Python steam-backlog-enforcer/steam_backlog_enforcer/main.py
|
||||
22796 556 41 Shell testsAndMisc/phone_focus_mode/config.sh
|
||||
18720 390 48 Python screen-locker/screen_locker/screen_lock.py
|
||||
17244 1437 12 TypeScript konbini-67/src/core/shift.test.ts
|
||||
15606 1734 9 Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
|
||||
14261 1097 13 TypeScript konbini-67/src/core/shift.ts
|
||||
14041 739 19 Dart todo/lib/ui/capture_screen.dart
|
||||
13635 909 15 Dart dufs-cloud/app/lib/screens/browser_screen.dart
|
||||
13068 1089 12 Dart dufs-cloud/app/test/browser_screen_test.dart
|
||||
12495 833 15 Shell testsAndMisc/phone_focus_mode/deploy.sh
|
||||
11220 660 17 Dart todo/test/capture_screen_test.dart
|
||||
10696 1337 8 Shell testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
|
||||
9954 553 18 Python steam-backlog-enforcer/steam_backlog_enforcer/game_install.py
|
||||
9819 1091 9 Shell testsAndMisc/phone_focus_mode/focus_ctl.sh
|
||||
9423 1047 9 TypeScript dufs-cloud/web/src/components/gallery.test.tsx
|
||||
9252 771 12 Dart screen-locker/stronglift_replacement/workout_app/lib/screens/settings_screen.dart
|
||||
7950 1325 6 Dart home_inventory/test/data/item_repository_test.dart
|
||||
7840 1120 7 Dart home_inventory/lib/data/item_repository.dart
|
||||
7215 555 13 Python steam-backlog-enforcer/steam_backlog_enforcer/library_hider.py
|
||||
7176 897 8 Dart screen-locker/stronglift_replacement/workout_app/lib/screens/history_screen.dart
|
||||
7170 478 15 Python wake-alarm/wake_alarm/_alarm.py
|
||||
7020 702 10 Dart todo/lib/ui/note_editor.dart
|
||||
6994 538 13 TypeScript konbini-67/src/app.test.tsx
|
||||
6773 521 13 Python steam-backlog-enforcer/steam_backlog_enforcer/scanning.py
|
||||
6768 752 9 Dart screen-locker/stronglift_replacement/workout_app/lib/services/storage_service.dart
|
||||
6699 319 21 Python screen-locker/screen_locker/tests/conftest.py
|
||||
6612 551 12 CSS europe-county-map/src/App.css
|
||||
6600 440 15 Dart diet-guard/app/lib/screens/log_meal_screen.dart
|
||||
6516 543 12 Shell testsAndMisc/phone_focus_mode/focus_daemon.sh
|
||||
6312 1052 6 Dart diet-guard/app/lib/screens/history_screen.dart
|
||||
|
||||
### DISTRIBUTION BY LANGUAGE (code only)
|
||||
|
||||
lang n median mean p75 p90 p95 p99 max
|
||||
---------- --- ------ ----- --- --- --- ---- ----
|
||||
Python 895 179 206.6 294 399 466 652 1076
|
||||
Dart 758 105 160.5 203 351 480 897 1417
|
||||
TypeScript 459 85 125.4 162 266 361 698 1437
|
||||
Shell 363 120 201.1 243 457 663 1276 1734
|
||||
Kotlin 68 26 71.0 69 223 277 564 564
|
||||
HTML 60 402 333.8 548 716 738 843 843
|
||||
C 22 21 70.5 88 229 249 478 478
|
||||
CSS 19 169 261.1 535 768 774 774 774
|
||||
Swift 17 12 12 15 16 16 16 16
|
||||
C++ 16 70 97.8 151 288 288 288 288
|
||||
JavaScript 15 47 65.7 97 134 179 179 179
|
||||
Java 8 40 69.2 101 247 247 247 247
|
||||
|
||||
### DISTRIBUTION BY PROJECT (code only, >=10 files)
|
||||
|
||||
project n median MEAN p90 max total
|
||||
---------------------- --- ------ ----- --- ---- -------
|
||||
steam-backlog-enforcer 114 269 301.3 588 1076 34,352
|
||||
epopeja_karta 37 192 289.4 759 1417 10,708
|
||||
kuhytrack 12 215 240 392 652 2,880
|
||||
screen-locker 211 169 209.8 390 897 44,261
|
||||
steam-game-installer 18 144 198.2 473 765 3,567
|
||||
utils 127 167 193.3 412 525 24,552
|
||||
mcp-servers 27 136 189.1 467 487 5,106
|
||||
testsAndMisc 745 127 187.7 421 1734 139,813
|
||||
europe-county-map 75 138 181.8 374 850 13,637
|
||||
manga_translate 40 144 181.2 417 720 7,250
|
||||
leetcode-guard 117 181 180.4 296 400 21,111
|
||||
wake-alarm 68 150 178.2 405 764 12,118
|
||||
diet-guard 217 128 175.6 379 1052 38,101
|
||||
yay-mcp 16 109 168.3 478 554 2,693
|
||||
todo 99 109 165.9 405 739 16,421
|
||||
i3wm-mcp 12 130 165.8 275 615 1,989
|
||||
home_inventory 113 96 152.6 328 1325 17,249
|
||||
konbini-67 73 96 149.6 209 1437 10,919
|
||||
dufs-cloud 126 99 146.8 257 1089 18,497
|
||||
hotline3d 38 82 136.4 353 736 5,184
|
||||
macro-cam 55 103 133.5 279 574 7,341
|
||||
wiki-kb 31 95 121.3 215 463 3,760
|
||||
atomichabits 15 88 109.8 188 251 1,647
|
||||
octoforge 76 80 104.3 209 510 7,929
|
||||
kuhylog 92 89 94.9 166 301 8,727
|
||||
.claude 17 64 74.5 177 311 1,267
|
||||
iron-and-anvil 40 55 71.7 159 220 2,869
|
||||
habit_stack 54 42 71.7 169 288 3,870
|
||||
personal-website 25 48 65.3 165 303 1,633
|
||||
|
||||
### COUNT OVER CANDIDATE THRESHOLDS (code only)
|
||||
|
||||
threshold files over % of code of those, edited last yr
|
||||
--------- ---------- --------- ------------------------
|
||||
300 474 17.6% 452
|
||||
400 243 9.0% 229
|
||||
500 118 4.4% 114
|
||||
600 82 3.0% 80
|
||||
800 33 1.2% 33
|
||||
1000 19 0.7% 19
|
||||
1500 1 0.0% 1
|
||||
2000 0 0.0% 0
|
||||
201
file_length/_tables.py
Normal file
201
file_length/_tables.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""Exemption tables for the 250-line file-length cap.
|
||||
|
||||
The data half: which extensions are capped, and which paths are generated,
|
||||
vendored, third-party or session artifacts. :mod:`file_length.exemptions`
|
||||
holds the rules that read these, and :mod:`file_length.config` re-exports
|
||||
both as the public surface.
|
||||
|
||||
Duplicating these lists in shell is what lets a gate and a survey silently
|
||||
disagree, so they live here once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: Hard cap. Files above this fail the gate. Code and prose alike.
|
||||
MAX_LINES = 250
|
||||
|
||||
#: Extensions the cap applies to: real programming languages plus prose.
|
||||
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into
|
||||
#: partials, not functions, so a line cap does not express anything useful.
|
||||
CAPPED_EXTENSIONS = {
|
||||
# code
|
||||
".py",
|
||||
".dart",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".c",
|
||||
".h",
|
||||
".cpp",
|
||||
".cc",
|
||||
".hpp",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".kt",
|
||||
".kts",
|
||||
".swift",
|
||||
".rb",
|
||||
".lua",
|
||||
".sql",
|
||||
".vue",
|
||||
".cs",
|
||||
".php",
|
||||
# prose
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".rst",
|
||||
".tex",
|
||||
".org",
|
||||
}
|
||||
|
||||
#: Generated files: regenerated by a tool, so splitting them is meaningless
|
||||
#: (the tool would just rewrite the original next run).
|
||||
GENERATED_PATTERN = re.compile(
|
||||
r"\.g\.dart$"
|
||||
r"|\.freezed\.dart$"
|
||||
r"|\.gr\.dart$"
|
||||
r"|\.config\.dart$"
|
||||
r"|\.mocks\.dart$"
|
||||
r"|\.min\.(js|css)$"
|
||||
r"|\.pb\.(go|py)$"
|
||||
r"|_pb2(_grpc)?\.py$"
|
||||
r"|\.generated\.ts$"
|
||||
r"|\.d\.ts$"
|
||||
r"|/l10n/generated/"
|
||||
r"|/generated/"
|
||||
)
|
||||
|
||||
#: A marker in the first few lines also marks a file generated. Covers tools
|
||||
#: that do not use a distinguishing extension.
|
||||
GENERATED_MARKERS = (
|
||||
"GENERATED CODE - DO NOT MODIFY",
|
||||
"@generated",
|
||||
"DO NOT EDIT",
|
||||
"Autogenerated",
|
||||
"AUTOGENERATED",
|
||||
"generated by",
|
||||
)
|
||||
|
||||
#: Directory names never descended into: dependencies, build output, caches.
|
||||
EXCLUDED_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
".dart_tool",
|
||||
"build",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"site-packages",
|
||||
"target",
|
||||
"ephemeral",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
".next",
|
||||
"vendor",
|
||||
"third_party",
|
||||
"Pods",
|
||||
".gradle",
|
||||
".idea",
|
||||
".cxx",
|
||||
"coverage",
|
||||
"htmlcov",
|
||||
".tox",
|
||||
"Library",
|
||||
"Temp",
|
||||
"obj",
|
||||
".terraform",
|
||||
".fvm",
|
||||
".angular",
|
||||
".cache",
|
||||
".svelte-kit",
|
||||
"migrations",
|
||||
".ci-mirror-venv",
|
||||
".hippo",
|
||||
".build-deps",
|
||||
# Where CI checks out the shared gate itself (kuhyx/utils). Without
|
||||
# this, a repo's lint job fails on utils' own long files -- a gate
|
||||
# that fails for reasons the repo being gated cannot fix.
|
||||
".utils",
|
||||
}
|
||||
)
|
||||
|
||||
#: Third-party content living *inside* a repo you own, keyed by repo dir name.
|
||||
#: Verified by git remote / vendor manifest, not guessed.
|
||||
VENDORED_SUBPATHS = {
|
||||
"mcp-servers": ("servers",),
|
||||
"dufs-cloud": ("app/assets/pdfjs", "web/assets/pdfjs"),
|
||||
".claude": (
|
||||
"projects",
|
||||
"shell-snapshots",
|
||||
"cache",
|
||||
"backups",
|
||||
"todos",
|
||||
"statsig",
|
||||
"history",
|
||||
"chrome",
|
||||
"downloads",
|
||||
"plugins",
|
||||
"ide",
|
||||
),
|
||||
# Agent skill bundles vendored from Anthropic (todo: commit f91311f).
|
||||
# A SKILL.md is a fixed-name file with frontmatter; detail belongs in a
|
||||
# sibling references/ dir, so splitting one into SKILL.part2.md would
|
||||
# break the format rather than improve readability.
|
||||
"todo": (".agents/skills",),
|
||||
}
|
||||
|
||||
#: Directory names that mark a vendored agent-skill tree wherever they appear.
|
||||
#: Keyed by name rather than repo because these bundles get dropped into any
|
||||
#: repo that wants them, not just the one they were first seen in.
|
||||
VENDORED_ANYWHERE = ("/.agents/skills/", "/.claude/skills/")
|
||||
|
||||
#: Frozen records of past agent sessions: an implementation plan or an
|
||||
#: append-only session log describes what was done at one moment. Splitting one
|
||||
#: rewrites a historical record for no reading benefit -- nobody navigates them,
|
||||
#: they are read whole or not at all. Same category as generated files.
|
||||
#:
|
||||
#: Deliberately NOT the whole of docs/superpowers/: `specs/` and `contracts/`
|
||||
#: stay capped, because those are living documents that get re-read and edited.
|
||||
SESSION_ARTIFACTS_ANYWHERE = (
|
||||
"/docs/superpowers/plans/",
|
||||
"/docs/superpowers/sessions/",
|
||||
)
|
||||
|
||||
#: Whole repos under ~ that are third-party clones, not kuhy's work.
|
||||
#: Verified 2026-08-14 by `git remote get-url origin` + commit authors.
|
||||
THIRD_PARTY_REPOS = frozenset(
|
||||
{
|
||||
"StringTheory", # github.com/AnthonySf/TheStringTheory
|
||||
"reaper-mcp", # github.com/TwelveTake-Studios/reaper-mcp
|
||||
"freesound-mcp", # github.com/timjrobinson/FreesoundMCPServer
|
||||
"llama_cpp_dart_local", # generated FFI bindings
|
||||
"aseprite-mcp",
|
||||
"Mapperatorinator", # github.com/OliBomby/Mapperatorinator
|
||||
"aur",
|
||||
"build-your-own-x",
|
||||
"build_your_x",
|
||||
"byox-ladder",
|
||||
"warriorjs",
|
||||
"untools",
|
||||
"awesome-mcp-explorer",
|
||||
"runnerup",
|
||||
"depotdownloader",
|
||||
"actions-runner",
|
||||
}
|
||||
)
|
||||
|
||||
#: A `.txt` whose mean line is shorter than this is data (a wordlist, a
|
||||
#: coordinate dump), not prose, so the cap does not apply.
|
||||
DATA_TXT_MAX_MEAN_LINE = 25
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,252 +1,45 @@
|
||||
"""Shared configuration for the 250-line file-length cap.
|
||||
|
||||
Single source of truth for the cap and its exemptions, imported by both the
|
||||
pre-commit gate (`check.py`) and the survey. Duplicating these lists in shell
|
||||
is what lets a gate and a survey silently disagree.
|
||||
pre-commit gate (`check.py`) and the survey. This module is the public
|
||||
surface: :mod:`file_length._tables` holds the data, and
|
||||
:mod:`file_length.exemptions` the rules that read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
#: Hard cap. Files above this fail the gate. Code and prose alike.
|
||||
MAX_LINES = 250
|
||||
|
||||
#: Extensions the cap applies to: real programming languages plus prose.
|
||||
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into
|
||||
#: partials, not functions, so a line cap does not express anything useful.
|
||||
CAPPED_EXTENSIONS = {
|
||||
# code
|
||||
".py",
|
||||
".dart",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".c",
|
||||
".h",
|
||||
".cpp",
|
||||
".cc",
|
||||
".hpp",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".kt",
|
||||
".kts",
|
||||
".swift",
|
||||
".rb",
|
||||
".lua",
|
||||
".sql",
|
||||
".vue",
|
||||
".cs",
|
||||
".php",
|
||||
# prose
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".rst",
|
||||
".tex",
|
||||
".org",
|
||||
}
|
||||
|
||||
#: Generated files: regenerated by a tool, so splitting them is meaningless
|
||||
#: (the tool would just rewrite the original next run).
|
||||
GENERATED_PATTERN = re.compile(
|
||||
r"\.g\.dart$"
|
||||
r"|\.freezed\.dart$"
|
||||
r"|\.gr\.dart$"
|
||||
r"|\.config\.dart$"
|
||||
r"|\.mocks\.dart$"
|
||||
r"|\.min\.(js|css)$"
|
||||
r"|\.pb\.(go|py)$"
|
||||
r"|_pb2(_grpc)?\.py$"
|
||||
r"|\.generated\.ts$"
|
||||
r"|\.d\.ts$"
|
||||
r"|/l10n/generated/"
|
||||
r"|/generated/"
|
||||
from file_length._tables import (
|
||||
CAPPED_EXTENSIONS,
|
||||
DATA_TXT_MAX_MEAN_LINE,
|
||||
EXCLUDED_DIRS,
|
||||
GENERATED_MARKERS,
|
||||
GENERATED_PATTERN,
|
||||
MAX_LINES,
|
||||
SESSION_ARTIFACTS_ANYWHERE,
|
||||
THIRD_PARTY_REPOS,
|
||||
VENDORED_ANYWHERE,
|
||||
VENDORED_SUBPATHS,
|
||||
)
|
||||
from file_length.exemptions import (
|
||||
is_data_text,
|
||||
is_generated,
|
||||
is_session_artifact,
|
||||
is_vendored,
|
||||
)
|
||||
|
||||
#: A marker in the first few lines also marks a file generated. Covers tools
|
||||
#: that do not use a distinguishing extension.
|
||||
GENERATED_MARKERS = (
|
||||
"GENERATED CODE - DO NOT MODIFY",
|
||||
"@generated",
|
||||
"DO NOT EDIT",
|
||||
"Autogenerated",
|
||||
"AUTOGENERATED",
|
||||
"generated by",
|
||||
)
|
||||
|
||||
#: Directory names never descended into: dependencies, build output, caches.
|
||||
EXCLUDED_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
".dart_tool",
|
||||
"build",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"site-packages",
|
||||
"target",
|
||||
"ephemeral",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
".next",
|
||||
"vendor",
|
||||
"third_party",
|
||||
"Pods",
|
||||
".gradle",
|
||||
".idea",
|
||||
".cxx",
|
||||
"coverage",
|
||||
"htmlcov",
|
||||
".tox",
|
||||
"Library",
|
||||
"Temp",
|
||||
"obj",
|
||||
".terraform",
|
||||
".fvm",
|
||||
".angular",
|
||||
".cache",
|
||||
".svelte-kit",
|
||||
"migrations",
|
||||
".ci-mirror-venv",
|
||||
".hippo",
|
||||
".build-deps",
|
||||
# Where CI checks out the shared gate itself (kuhyx/utils). Without
|
||||
# this, a repo's lint job fails on utils' own long files -- a gate
|
||||
# that fails for reasons the repo being gated cannot fix.
|
||||
".utils",
|
||||
}
|
||||
)
|
||||
|
||||
#: Third-party content living *inside* a repo you own, keyed by repo dir name.
|
||||
#: Verified by git remote / vendor manifest, not guessed.
|
||||
VENDORED_SUBPATHS = {
|
||||
"mcp-servers": ("servers",),
|
||||
"dufs-cloud": ("app/assets/pdfjs", "web/assets/pdfjs"),
|
||||
".claude": (
|
||||
"projects",
|
||||
"shell-snapshots",
|
||||
"cache",
|
||||
"backups",
|
||||
"todos",
|
||||
"statsig",
|
||||
"history",
|
||||
"chrome",
|
||||
"downloads",
|
||||
"plugins",
|
||||
"ide",
|
||||
),
|
||||
# Agent skill bundles vendored from Anthropic (todo: commit f91311f).
|
||||
# A SKILL.md is a fixed-name file with frontmatter; detail belongs in a
|
||||
# sibling references/ dir, so splitting one into SKILL.part2.md would
|
||||
# break the format rather than improve readability.
|
||||
"todo": (".agents/skills",),
|
||||
}
|
||||
|
||||
#: Directory names that mark a vendored agent-skill tree wherever they appear.
|
||||
#: Keyed by name rather than repo because these bundles get dropped into any
|
||||
#: repo that wants them, not just the one they were first seen in.
|
||||
VENDORED_ANYWHERE = ("/.agents/skills/", "/.claude/skills/")
|
||||
|
||||
#: Frozen records of past agent sessions: an implementation plan or an
|
||||
#: append-only session log describes what was done at one moment. Splitting one
|
||||
#: rewrites a historical record for no reading benefit -- nobody navigates them,
|
||||
#: they are read whole or not at all. Same category as generated files.
|
||||
#:
|
||||
#: Deliberately NOT the whole of docs/superpowers/: `specs/` and `contracts/`
|
||||
#: stay capped, because those are living documents that get re-read and edited.
|
||||
SESSION_ARTIFACTS_ANYWHERE = (
|
||||
"/docs/superpowers/plans/",
|
||||
"/docs/superpowers/sessions/",
|
||||
)
|
||||
|
||||
#: Whole repos under ~ that are third-party clones, not kuhy's work.
|
||||
#: Verified 2026-08-14 by `git remote get-url origin` + commit authors.
|
||||
THIRD_PARTY_REPOS = frozenset(
|
||||
{
|
||||
"StringTheory", # github.com/AnthonySf/TheStringTheory
|
||||
"reaper-mcp", # github.com/TwelveTake-Studios/reaper-mcp
|
||||
"freesound-mcp", # github.com/timjrobinson/FreesoundMCPServer
|
||||
"llama_cpp_dart_local", # generated FFI bindings
|
||||
"aseprite-mcp",
|
||||
"Mapperatorinator", # github.com/OliBomby/Mapperatorinator
|
||||
"aur",
|
||||
"build-your-own-x",
|
||||
"build_your_x",
|
||||
"byox-ladder",
|
||||
"warriorjs",
|
||||
"untools",
|
||||
"awesome-mcp-explorer",
|
||||
"runnerup",
|
||||
"depotdownloader",
|
||||
"actions-runner",
|
||||
}
|
||||
)
|
||||
|
||||
#: A `.txt` whose mean line is shorter than this is data (a wordlist, a
|
||||
#: coordinate dump), not prose, so the cap does not apply.
|
||||
DATA_TXT_MAX_MEAN_LINE = 25
|
||||
|
||||
|
||||
def is_generated(path: Path) -> bool:
|
||||
"""True if the file is tool-generated and must not be split by hand."""
|
||||
if GENERATED_PATTERN.search(path.as_posix()):
|
||||
return True
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
head = "".join(next(handle, "") for _ in range(5))
|
||||
except OSError:
|
||||
return False
|
||||
return any(marker in head for marker in GENERATED_MARKERS)
|
||||
|
||||
|
||||
def is_data_text(path: Path, lines: int, size: int) -> bool:
|
||||
"""True for wordlist-style .txt: many lines, very short ones."""
|
||||
if path.suffix.lower() != ".txt" or lines <= 0:
|
||||
return False
|
||||
return (size / lines) < DATA_TXT_MAX_MEAN_LINE
|
||||
|
||||
|
||||
def is_vendored(path: Path) -> bool:
|
||||
"""True if the path sits under a known vendored subtree or excluded dir.
|
||||
|
||||
Callers must pass an absolute path (see `check.absolutize`). Every rule
|
||||
below needs the repo-name context that a repo-relative path has already
|
||||
thrown away: '/.claude/skills/' cannot match 'skills/x.md', so a relative
|
||||
path would quietly skip the exemption instead of applying it.
|
||||
"""
|
||||
parts = path.parts
|
||||
if any(part in EXCLUDED_DIRS for part in parts):
|
||||
return True
|
||||
posix = path.as_posix()
|
||||
if any(marker in posix for marker in VENDORED_ANYWHERE):
|
||||
return True
|
||||
for repo, subs in VENDORED_SUBPATHS.items():
|
||||
for sub in subs:
|
||||
if f"/{repo}/{sub}/" in posix:
|
||||
return True
|
||||
return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS)
|
||||
|
||||
|
||||
def is_session_artifact(path: Path) -> bool:
|
||||
"""True for a frozen agent-session record (a plan or a session log).
|
||||
|
||||
Kept separate from `is_vendored` so the gate reports why a file is exempt:
|
||||
these are kuhy's own files, not third-party code.
|
||||
|
||||
Callers must pass an absolute path (see `check.absolutize`), for the same
|
||||
reason `is_vendored` does -- a repo-relative path has already dropped the
|
||||
leading directory these markers match on.
|
||||
"""
|
||||
posix = path.as_posix()
|
||||
return any(marker in posix for marker in SESSION_ARTIFACTS_ANYWHERE)
|
||||
__all__ = [
|
||||
"CAPPED_EXTENSIONS",
|
||||
"DATA_TXT_MAX_MEAN_LINE",
|
||||
"EXCLUDED_DIRS",
|
||||
"GENERATED_MARKERS",
|
||||
"GENERATED_PATTERN",
|
||||
"MAX_LINES",
|
||||
"SESSION_ARTIFACTS_ANYWHERE",
|
||||
"THIRD_PARTY_REPOS",
|
||||
"VENDORED_ANYWHERE",
|
||||
"VENDORED_SUBPATHS",
|
||||
"is_data_text",
|
||||
"is_generated",
|
||||
"is_session_artifact",
|
||||
"is_vendored",
|
||||
]
|
||||
|
||||
82
file_length/exemptions.py
Normal file
82
file_length/exemptions.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Predicates deciding whether a file is exempt from the 250-line cap.
|
||||
|
||||
Split from :mod:`file_length.config`, which holds the tables these read.
|
||||
Keeping the rules separate from the data they consult means a new exemption
|
||||
is a table entry, not a new branch, and the gate and the survey keep sharing
|
||||
one definition of "exempt".
|
||||
|
||||
Re-exported from :mod:`file_length.config`, so existing
|
||||
``from file_length.config import is_generated`` imports keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from file_length._tables import (
|
||||
DATA_TXT_MAX_MEAN_LINE,
|
||||
EXCLUDED_DIRS,
|
||||
GENERATED_MARKERS,
|
||||
GENERATED_PATTERN,
|
||||
SESSION_ARTIFACTS_ANYWHERE,
|
||||
THIRD_PARTY_REPOS,
|
||||
VENDORED_ANYWHERE,
|
||||
VENDORED_SUBPATHS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_generated(path: Path) -> bool:
|
||||
"""True if the file is tool-generated and must not be split by hand."""
|
||||
if GENERATED_PATTERN.search(path.as_posix()):
|
||||
return True
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
head = "".join(next(handle, "") for _ in range(5))
|
||||
except OSError:
|
||||
return False
|
||||
return any(marker in head for marker in GENERATED_MARKERS)
|
||||
|
||||
|
||||
def is_data_text(path: Path, lines: int, size: int) -> bool:
|
||||
"""True for wordlist-style .txt: many lines, very short ones."""
|
||||
if path.suffix.lower() != ".txt" or lines <= 0:
|
||||
return False
|
||||
return (size / lines) < DATA_TXT_MAX_MEAN_LINE
|
||||
|
||||
|
||||
def is_vendored(path: Path) -> bool:
|
||||
"""True if the path sits under a known vendored subtree or excluded dir.
|
||||
|
||||
Callers must pass an absolute path (see `check.absolutize`). Every rule
|
||||
below needs the repo-name context that a repo-relative path has already
|
||||
thrown away: '/.claude/skills/' cannot match 'skills/x.md', so a relative
|
||||
path would quietly skip the exemption instead of applying it.
|
||||
"""
|
||||
parts = path.parts
|
||||
if any(part in EXCLUDED_DIRS for part in parts):
|
||||
return True
|
||||
posix = path.as_posix()
|
||||
if any(marker in posix for marker in VENDORED_ANYWHERE):
|
||||
return True
|
||||
for repo, subs in VENDORED_SUBPATHS.items():
|
||||
for sub in subs:
|
||||
if f"/{repo}/{sub}/" in posix:
|
||||
return True
|
||||
return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS)
|
||||
|
||||
|
||||
def is_session_artifact(path: Path) -> bool:
|
||||
"""True for a frozen agent-session record (a plan or a session log).
|
||||
|
||||
Kept separate from `is_vendored` so the gate reports why a file is exempt:
|
||||
these are kuhy's own files, not third-party code.
|
||||
|
||||
Callers must pass an absolute path (see `check.absolutize`), for the same
|
||||
reason `is_vendored` does -- a repo-relative path has already dropped the
|
||||
leading directory these markers match on.
|
||||
"""
|
||||
posix = path.as_posix()
|
||||
return any(marker in posix for marker in SESSION_ARTIFACTS_ANYWHERE)
|
||||
@ -18,6 +18,8 @@ fi
|
||||
source "$LIB_DIR/common.sh"
|
||||
# shellcheck source=lib/file_guard.sh
|
||||
source "$LIB_DIR/file_guard.sh"
|
||||
# shellcheck source=lib/file_guard_unlock.sh
|
||||
source "$LIB_DIR/file_guard_unlock.sh"
|
||||
# shellcheck source=lib/package_block.sh
|
||||
source "$LIB_DIR/package_block.sh"
|
||||
|
||||
|
||||
@ -138,213 +138,3 @@ fg_enforce() {
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||
log_hook "file-guard $name: enforce ok"
|
||||
}
|
||||
|
||||
fg_unlock() {
|
||||
local name="$1"
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
# shellcheck disable=SC2153 # PLUGIN set by load_target_conf, not a typo
|
||||
load_plugin "$PLUGIN"
|
||||
echo "About to unlock file-guard '$name' ($TARGET) for editing."
|
||||
read -rp "Reason for unlocking: " reason
|
||||
[[ -n "$reason" ]] || die "a reason is required"
|
||||
log_hook "file-guard $name: UNLOCK requested by ${SUDO_USER:-$(whoami)}: $reason"
|
||||
echo "Unlocking in 45s (Ctrl-C to abort)..."
|
||||
sleep 45
|
||||
# Stop the watcher before unlocking: chattr alone (no content write)
|
||||
# fires PathModified, and the resulting enforce pass unconditionally
|
||||
# re-locks the target even with no drift - which would silently
|
||||
# re-lock this file out from under the editor. Confirmed live during
|
||||
# the shutdown-schedule migration (see guard-lib project notes).
|
||||
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
log_hook "file-guard $name: unlocked for editing"
|
||||
|
||||
local before after
|
||||
before="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
# Deliberately unquoted: $EDITOR commonly carries arguments (e.g.
|
||||
# "code --wait", "emacsclient -t") that must be word-split.
|
||||
${EDITOR:-vi} "$TARGET"
|
||||
after="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
if [[ "$before" != "$after" ]]; then
|
||||
if declare -f validate >/dev/null 2>&1 && ! validate "$TARGET"; then
|
||||
echo "New content fails this instance's validate() check - reverting your edit; canonical unchanged." >&2
|
||||
log_hook "file-guard $name: edit REJECTED (failed validate), reverting to canonical"
|
||||
[[ -e "$CANONICAL" ]] && cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
|
||||
else
|
||||
# Update canonical to match: without this, re-locking below
|
||||
# fires the watcher, which would see the new content as
|
||||
# "drift" against the stale canonical and silently revert
|
||||
# this legitimate edit.
|
||||
log_hook "file-guard $name: content changed, updating canonical"
|
||||
unlock_file "$CANONICAL"
|
||||
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
|
||||
lock_file "$CANONICAL"
|
||||
fi
|
||||
else
|
||||
log_hook "file-guard $name: no content change"
|
||||
fi
|
||||
|
||||
lock_file "$TARGET"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: re-locked after edit"
|
||||
echo "Re-locked '$name'."
|
||||
}
|
||||
|
||||
fg_uninstall() {
|
||||
local name="$1"
|
||||
shift
|
||||
local keep_canonical=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep-canonical) keep_canonical=1; shift ;;
|
||||
*) die "file-guard uninstall: unknown argument '$1'" ;;
|
||||
esac
|
||||
done
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
systemctl disable --now "guard-file@$name.path" 2>/dev/null || true
|
||||
systemctl disable --now "guard-bind-mount@$name.service" 2>/dev/null || true
|
||||
rm -rf "${GUARD_LIB_SYSTEMD_DIR:?}/guard-file@$name.path.d"
|
||||
systemctl daemon-reload
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
if [[ "$keep_canonical" -eq 0 ]]; then
|
||||
unlock_file "$CANONICAL"
|
||||
rm -f "$CANONICAL"
|
||||
fi
|
||||
rm -f "$(target_conf_path "$name")"
|
||||
log_hook "file-guard $name: uninstalled (keep_canonical=$keep_canonical)"
|
||||
echo "uninstalled file-guard '$name'"
|
||||
}
|
||||
|
||||
fg_status() {
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
echo "name: $name"
|
||||
echo "target: $TARGET"
|
||||
echo "canonical: $CANONICAL"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT/PLUGIN/ALSO_WATCH set by load_target_conf, not typos
|
||||
echo "bind_mount: $BIND_MOUNT"
|
||||
echo "plugin: ${PLUGIN:-<none>}"
|
||||
echo "also_watch: ${ALSO_WATCH:-<none>}"
|
||||
if [[ -e "$TARGET" ]]; then
|
||||
local attrs
|
||||
attrs="$(lsattr "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
echo "target attrs: ${attrs:-unknown}"
|
||||
else
|
||||
echo "target: MISSING"
|
||||
fi
|
||||
local unit_state
|
||||
unit_state="$(systemctl is-active "guard-file@$name.path" 2>/dev/null || true)"
|
||||
echo "path unit: ${unit_state:-unknown}"
|
||||
}
|
||||
|
||||
fg_canonical_path() {
|
||||
# Machine-readable single-value output: the canonical path for an
|
||||
# instance, so consumers with bespoke unlock flows (that can't go
|
||||
# through `unlock`/`enforce` directly) don't have to hardcode or
|
||||
# duplicate guard-lib's canonical-path convention.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
echo "$CANONICAL"
|
||||
}
|
||||
|
||||
fg_pacman_unlock() {
|
||||
# Quiet, non-interactive unlock used only by the generic pacman
|
||||
# PreTransaction hook - no typed reason, no editor. The pacman
|
||||
# transaction itself is the sanctioned reason.
|
||||
#
|
||||
# Stops the path unit, not just chattr -i's the target: chattr alone
|
||||
# fires PathModified, and if the watcher's enforce pass wins the race
|
||||
# against pacman's own write, it silently re-locks the file before
|
||||
# pacman gets to it, breaking the very transaction this hook exists
|
||||
# to allow. Matches the pre-existing hosts-guard pacman hooks, which
|
||||
# stop hosts-guard.path/nsswitch-guard.path/resolved-guard.path for
|
||||
# the same reason.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
log_hook "file-guard $name: pacman-unlock (pre-transaction)"
|
||||
}
|
||||
|
||||
fg_pacman_relock() {
|
||||
# Counterpart to fg_pacman_unlock, used only by the generic pacman
|
||||
# PostTransaction hook: re-enforce (drift-restore + chattr +i, and -
|
||||
# if this instance is bind-mounted - re-establish the read-only
|
||||
# bind mount, since fg_enforce does that itself now) then restart
|
||||
# the path unit that pacman-unlock stopped.
|
||||
local name="$1"
|
||||
fg_enforce "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: pacman-relock (post-transaction)"
|
||||
}
|
||||
|
||||
fg_sync_canonical() {
|
||||
# Counterpart to fg_pacman_unlock for callers that legitimately EDITED
|
||||
# the target (not pacman's own hook flow, which wants drift reverted).
|
||||
# Adopts the target's current content as the new canonical, instead of
|
||||
# fg_enforce's drift-check-and-revert - which would otherwise silently
|
||||
# undo the very edit the caller just made. Assumes the watcher was
|
||||
# already stopped by a prior fg_pacman_unlock; does not stop it itself.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
snapshot_canonical "$name"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
lock_file "$TARGET"
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: synced canonical to current content"
|
||||
}
|
||||
|
||||
# Self-bind-mounts TARGET over itself, then remounts that layer
|
||||
# read-only. This is a mount-level lock independent of chattr: even a
|
||||
# successful `chattr -i` bypass still can't write through a read-only
|
||||
# mount. Deliberately does NOT bind CANONICAL over TARGET - that would
|
||||
# merge them onto the same inode, so chattr/writes to either would hit
|
||||
# both and "restore from canonical" would become meaningless.
|
||||
_reapply_bind_mount() {
|
||||
local name="$1"
|
||||
if mountpoint -q "$TARGET" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
mount --bind "$TARGET" "$TARGET"
|
||||
mount -o remount,ro,bind "$TARGET"
|
||||
log_hook "file-guard $name: bind-mounted $TARGET read-only"
|
||||
}
|
||||
|
||||
fg_bind_mount() {
|
||||
local name="$1"
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
_reapply_bind_mount "$name"
|
||||
}
|
||||
|
||||
file_guard_main() {
|
||||
local sub="$1"
|
||||
shift || true
|
||||
case "$sub" in
|
||||
install) fg_install "$@" ;;
|
||||
enforce) fg_enforce "$@" ;;
|
||||
unlock) fg_unlock "$@" ;;
|
||||
pacman-unlock) fg_pacman_unlock "$@" ;;
|
||||
uninstall) fg_uninstall "$@" ;;
|
||||
status) fg_status "$@" ;;
|
||||
canonical-path) fg_canonical_path "$@" ;;
|
||||
bind-mount) fg_bind_mount "$@" ;;
|
||||
pacman-relock) fg_pacman_relock "$@" ;;
|
||||
sync) fg_sync_canonical "$@" ;;
|
||||
*) die "file-guard: unknown subcommand '$sub' (expected install|enforce|unlock|pacman-unlock|pacman-relock|sync|uninstall|status|canonical-path|bind-mount)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
214
guard-lib/lib/file_guard_unlock.sh
Normal file
214
guard-lib/lib/file_guard_unlock.sh
Normal file
@ -0,0 +1,214 @@
|
||||
#!/bin/bash
|
||||
# guardctl file-guard unlocking, pacman coordination and dispatch.
|
||||
# Split from file_guard.sh for the shared 250-line cap; sourced by
|
||||
# guardctl right after it, never executed directly.
|
||||
|
||||
fg_unlock() {
|
||||
local name="$1"
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
# shellcheck disable=SC2153 # PLUGIN set by load_target_conf, not a typo
|
||||
load_plugin "$PLUGIN"
|
||||
echo "About to unlock file-guard '$name' ($TARGET) for editing."
|
||||
read -rp "Reason for unlocking: " reason
|
||||
[[ -n "$reason" ]] || die "a reason is required"
|
||||
log_hook "file-guard $name: UNLOCK requested by ${SUDO_USER:-$(whoami)}: $reason"
|
||||
echo "Unlocking in 45s (Ctrl-C to abort)..."
|
||||
sleep 45
|
||||
# Stop the watcher before unlocking: chattr alone (no content write)
|
||||
# fires PathModified, and the resulting enforce pass unconditionally
|
||||
# re-locks the target even with no drift - which would silently
|
||||
# re-lock this file out from under the editor. Confirmed live during
|
||||
# the shutdown-schedule migration (see guard-lib project notes).
|
||||
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
log_hook "file-guard $name: unlocked for editing"
|
||||
|
||||
local before after
|
||||
before="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
# Deliberately unquoted: $EDITOR commonly carries arguments (e.g.
|
||||
# "code --wait", "emacsclient -t") that must be word-split.
|
||||
${EDITOR:-vi} "$TARGET"
|
||||
after="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
if [[ "$before" != "$after" ]]; then
|
||||
if declare -f validate >/dev/null 2>&1 && ! validate "$TARGET"; then
|
||||
echo "New content fails this instance's validate() check - reverting your edit; canonical unchanged." >&2
|
||||
log_hook "file-guard $name: edit REJECTED (failed validate), reverting to canonical"
|
||||
[[ -e "$CANONICAL" ]] && cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
|
||||
else
|
||||
# Update canonical to match: without this, re-locking below
|
||||
# fires the watcher, which would see the new content as
|
||||
# "drift" against the stale canonical and silently revert
|
||||
# this legitimate edit.
|
||||
log_hook "file-guard $name: content changed, updating canonical"
|
||||
unlock_file "$CANONICAL"
|
||||
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
|
||||
lock_file "$CANONICAL"
|
||||
fi
|
||||
else
|
||||
log_hook "file-guard $name: no content change"
|
||||
fi
|
||||
|
||||
lock_file "$TARGET"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: re-locked after edit"
|
||||
echo "Re-locked '$name'."
|
||||
}
|
||||
|
||||
fg_uninstall() {
|
||||
local name="$1"
|
||||
shift
|
||||
local keep_canonical=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep-canonical) keep_canonical=1; shift ;;
|
||||
*) die "file-guard uninstall: unknown argument '$1'" ;;
|
||||
esac
|
||||
done
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
systemctl disable --now "guard-file@$name.path" 2>/dev/null || true
|
||||
systemctl disable --now "guard-bind-mount@$name.service" 2>/dev/null || true
|
||||
rm -rf "${GUARD_LIB_SYSTEMD_DIR:?}/guard-file@$name.path.d"
|
||||
systemctl daemon-reload
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
if [[ "$keep_canonical" -eq 0 ]]; then
|
||||
unlock_file "$CANONICAL"
|
||||
rm -f "$CANONICAL"
|
||||
fi
|
||||
rm -f "$(target_conf_path "$name")"
|
||||
log_hook "file-guard $name: uninstalled (keep_canonical=$keep_canonical)"
|
||||
echo "uninstalled file-guard '$name'"
|
||||
}
|
||||
|
||||
fg_status() {
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
echo "name: $name"
|
||||
echo "target: $TARGET"
|
||||
echo "canonical: $CANONICAL"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT/PLUGIN/ALSO_WATCH set by load_target_conf, not typos
|
||||
echo "bind_mount: $BIND_MOUNT"
|
||||
echo "plugin: ${PLUGIN:-<none>}"
|
||||
echo "also_watch: ${ALSO_WATCH:-<none>}"
|
||||
if [[ -e "$TARGET" ]]; then
|
||||
local attrs
|
||||
attrs="$(lsattr "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||
echo "target attrs: ${attrs:-unknown}"
|
||||
else
|
||||
echo "target: MISSING"
|
||||
fi
|
||||
local unit_state
|
||||
unit_state="$(systemctl is-active "guard-file@$name.path" 2>/dev/null || true)"
|
||||
echo "path unit: ${unit_state:-unknown}"
|
||||
}
|
||||
|
||||
fg_canonical_path() {
|
||||
# Machine-readable single-value output: the canonical path for an
|
||||
# instance, so consumers with bespoke unlock flows (that can't go
|
||||
# through `unlock`/`enforce` directly) don't have to hardcode or
|
||||
# duplicate guard-lib's canonical-path convention.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
echo "$CANONICAL"
|
||||
}
|
||||
|
||||
fg_pacman_unlock() {
|
||||
# Quiet, non-interactive unlock used only by the generic pacman
|
||||
# PreTransaction hook - no typed reason, no editor. The pacman
|
||||
# transaction itself is the sanctioned reason.
|
||||
#
|
||||
# Stops the path unit, not just chattr -i's the target: chattr alone
|
||||
# fires PathModified, and if the watcher's enforce pass wins the race
|
||||
# against pacman's own write, it silently re-locks the file before
|
||||
# pacman gets to it, breaking the very transaction this hook exists
|
||||
# to allow. Matches the pre-existing hosts-guard pacman hooks, which
|
||||
# stop hosts-guard.path/nsswitch-guard.path/resolved-guard.path for
|
||||
# the same reason.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
log_hook "file-guard $name: pacman-unlock (pre-transaction)"
|
||||
}
|
||||
|
||||
fg_pacman_relock() {
|
||||
# Counterpart to fg_pacman_unlock, used only by the generic pacman
|
||||
# PostTransaction hook: re-enforce (drift-restore + chattr +i, and -
|
||||
# if this instance is bind-mounted - re-establish the read-only
|
||||
# bind mount, since fg_enforce does that itself now) then restart
|
||||
# the path unit that pacman-unlock stopped.
|
||||
local name="$1"
|
||||
fg_enforce "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: pacman-relock (post-transaction)"
|
||||
}
|
||||
|
||||
fg_sync_canonical() {
|
||||
# Counterpart to fg_pacman_unlock for callers that legitimately EDITED
|
||||
# the target (not pacman's own hook flow, which wants drift reverted).
|
||||
# Adopts the target's current content as the new canonical, instead of
|
||||
# fg_enforce's drift-check-and-revert - which would otherwise silently
|
||||
# undo the very edit the caller just made. Assumes the watcher was
|
||||
# already stopped by a prior fg_pacman_unlock; does not stop it itself.
|
||||
local name="$1"
|
||||
load_target_conf "$name"
|
||||
snapshot_canonical "$name"
|
||||
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||
unlock_file "$TARGET"
|
||||
lock_file "$TARGET"
|
||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||
log_hook "file-guard $name: synced canonical to current content"
|
||||
}
|
||||
|
||||
# Self-bind-mounts TARGET over itself, then remounts that layer
|
||||
# read-only. This is a mount-level lock independent of chattr: even a
|
||||
# successful `chattr -i` bypass still can't write through a read-only
|
||||
# mount. Deliberately does NOT bind CANONICAL over TARGET - that would
|
||||
# merge them onto the same inode, so chattr/writes to either would hit
|
||||
# both and "restore from canonical" would become meaningless.
|
||||
_reapply_bind_mount() {
|
||||
local name="$1"
|
||||
if mountpoint -q "$TARGET" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
mount --bind "$TARGET" "$TARGET"
|
||||
mount -o remount,ro,bind "$TARGET"
|
||||
log_hook "file-guard $name: bind-mounted $TARGET read-only"
|
||||
}
|
||||
|
||||
fg_bind_mount() {
|
||||
local name="$1"
|
||||
require_root
|
||||
load_target_conf "$name"
|
||||
_reapply_bind_mount "$name"
|
||||
}
|
||||
|
||||
file_guard_main() {
|
||||
local sub="$1"
|
||||
shift || true
|
||||
case "$sub" in
|
||||
install) fg_install "$@" ;;
|
||||
enforce) fg_enforce "$@" ;;
|
||||
unlock) fg_unlock "$@" ;;
|
||||
pacman-unlock) fg_pacman_unlock "$@" ;;
|
||||
uninstall) fg_uninstall "$@" ;;
|
||||
status) fg_status "$@" ;;
|
||||
canonical-path) fg_canonical_path "$@" ;;
|
||||
bind-mount) fg_bind_mount "$@" ;;
|
||||
pacman-relock) fg_pacman_relock "$@" ;;
|
||||
sync) fg_sync_canonical "$@" ;;
|
||||
*) die "file-guard: unknown subcommand '$sub' (expected install|enforce|unlock|pacman-unlock|pacman-relock|sync|uninstall|status|canonical-path|bind-mount)" ;;
|
||||
esac
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user