mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +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
|
# Phase 1: wait for registration, then for the key to work
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
register_status() {
|
# The rest lives in lib/, sourced (not executed) so every function sees the
|
||||||
python3 "$GATE" /register 2>/dev/null | head -1
|
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
|
||||||
}
|
# shellcheck source=lib/aur_registration.sh
|
||||||
|
source "$SCRIPT_DIR/lib/aur_registration.sh"
|
||||||
open_browser() {
|
# shellcheck source=lib/aur_verify.sh
|
||||||
local url="$1"
|
source "$SCRIPT_DIR/lib/aur_verify.sh"
|
||||||
local browser
|
# shellcheck source=lib/aur_publish_one.sh
|
||||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
require_tools
|
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.
|
"""Shared configuration for the 250-line file-length cap.
|
||||||
|
|
||||||
Single source of truth for the cap and its exemptions, imported by both the
|
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
|
pre-commit gate (`check.py`) and the survey. This module is the public
|
||||||
is what lets a gate and a survey silently disagree.
|
surface: :mod:`file_length._tables` holds the data, and
|
||||||
|
:mod:`file_length.exemptions` the rules that read it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from file_length._tables import (
|
||||||
import re
|
CAPPED_EXTENSIONS,
|
||||||
|
DATA_TXT_MAX_MEAN_LINE,
|
||||||
#: Hard cap. Files above this fail the gate. Code and prose alike.
|
EXCLUDED_DIRS,
|
||||||
MAX_LINES = 250
|
GENERATED_MARKERS,
|
||||||
|
GENERATED_PATTERN,
|
||||||
#: Extensions the cap applies to: real programming languages plus prose.
|
MAX_LINES,
|
||||||
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into
|
SESSION_ARTIFACTS_ANYWHERE,
|
||||||
#: partials, not functions, so a line cap does not express anything useful.
|
THIRD_PARTY_REPOS,
|
||||||
CAPPED_EXTENSIONS = {
|
VENDORED_ANYWHERE,
|
||||||
# code
|
VENDORED_SUBPATHS,
|
||||||
".py",
|
)
|
||||||
".dart",
|
from file_length.exemptions import (
|
||||||
".ts",
|
is_data_text,
|
||||||
".tsx",
|
is_generated,
|
||||||
".js",
|
is_session_artifact,
|
||||||
".jsx",
|
is_vendored,
|
||||||
".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
|
__all__ = [
|
||||||
#: that do not use a distinguishing extension.
|
"CAPPED_EXTENSIONS",
|
||||||
GENERATED_MARKERS = (
|
"DATA_TXT_MAX_MEAN_LINE",
|
||||||
"GENERATED CODE - DO NOT MODIFY",
|
"EXCLUDED_DIRS",
|
||||||
"@generated",
|
"GENERATED_MARKERS",
|
||||||
"DO NOT EDIT",
|
"GENERATED_PATTERN",
|
||||||
"Autogenerated",
|
"MAX_LINES",
|
||||||
"AUTOGENERATED",
|
"SESSION_ARTIFACTS_ANYWHERE",
|
||||||
"generated by",
|
"THIRD_PARTY_REPOS",
|
||||||
)
|
"VENDORED_ANYWHERE",
|
||||||
|
"VENDORED_SUBPATHS",
|
||||||
#: Directory names never descended into: dependencies, build output, caches.
|
"is_data_text",
|
||||||
EXCLUDED_DIRS = frozenset(
|
"is_generated",
|
||||||
{
|
"is_session_artifact",
|
||||||
".git",
|
"is_vendored",
|
||||||
"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)
|
|
||||||
|
|||||||
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"
|
source "$LIB_DIR/common.sh"
|
||||||
# shellcheck source=lib/file_guard.sh
|
# shellcheck source=lib/file_guard.sh
|
||||||
source "$LIB_DIR/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
|
# shellcheck source=lib/package_block.sh
|
||||||
source "$LIB_DIR/package_block.sh"
|
source "$LIB_DIR/package_block.sh"
|
||||||
|
|
||||||
|
|||||||
@ -138,213 +138,3 @@ fg_enforce() {
|
|||||||
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||||
log_hook "file-guard $name: enforce ok"
|
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