Files
Panama/tests/setup/user-content-contract
T

185 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# The personal half of the repository.
#
# user/ is somebody's actual agent instructions and SSH host aliases, tracked in
# a repository other people are meant to clone. That is only defensible under
# three rules, and this pins all three:
#
# 1. Nothing is linked on a machine that did not say yes. A stranger who runs
# ./install and answers the default gets their own ~/.claude/CLAUDE.md left
# exactly where it was.
# 2. Nothing already in a destination is destroyed. It moves to config/old/,
# the same promise link-dotfiles makes.
# 3. No secrets. The directory is world-readable to anyone who finds the
# repository, so a private key or token committed here is a disclosure and
# not a mistake to be caught in review.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
linker="$repo_dir/setup/scripts/link-user"
manifest="$repo_dir/user/manifest"
interview="$repo_dir/setup/scripts/interview"
installer="$repo_dir/install"
findings=()
note() { findings+=("$1"); }
[[ -x "$linker" ]] || { printf 'user content contract: %s is not executable\n' "$linker" >&2; exit 1; }
[[ -r "$manifest" ]] || { printf 'user content contract: no manifest at %s\n' "$manifest" >&2; exit 1; }
# ── 3. No secrets ───────────────────────────────────────────────────────────
#
# Checked first, because it is the one failure that cannot be undone by fixing
# the code: a key that reached a commit is a key that must be rotated.
if grep -rlqE 'BEGIN [A-Z ]*PRIVATE KEY' "$repo_dir/user" 2>/dev/null; then
note 'a private key is committed under user/'
fi
if grep -rlqE 'sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$repo_dir/user" 2>/dev/null; then
note 'something that looks like an API token is committed under user/'
fi
while read -r candidate; do
note "user/ carries a credentials file: ${candidate#"$repo_dir"/}"
done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name '.credentials.json' \) 2>/dev/null)
# ── Every manifest line is usable ───────────────────────────────────────────
while read -r kind source destination; do
[[ -e "$repo_dir/user/$source" ]] \
|| note "the manifest points at $source, which is not in user/"
[[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|| note "the manifest uses an unknown kind: $kind"
[[ "$destination" == '~/'* ]] \
|| note "the manifest destination $destination is not under the home directory"
done < <(grep -vE '^\s*(#|$)' "$manifest")
# ── The behaviour, against a fake home ──────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# A checkout of its own, so the test never links anything into the real home and
# never moves a real file into the real config/old.
checkout="$work/Panama"
mkdir -p "$checkout/setup/scripts" "$checkout/user/agents"
cp "$linker" "$checkout/setup/scripts/link-user"
printf 'tracked instructions\n' >"$checkout/user/agents/AGENTS.md"
mkdir -p "$checkout/user/agents/skills/example"
printf 'a skill\n' >"$checkout/user/agents/skills/example/SKILL.md"
printf 'copied once\n' >"$checkout/user/plain.txt"
cat >"$checkout/user/manifest" <<'FIXTURE'
# a comment, and a blank line follow
link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt
FIXTURE
home="$work/home"
run() {
HOME="$home" XDG_STATE_HOME="$work/state" PANAMA_PATH="$checkout" \
"$checkout/setup/scripts/link-user" "$@" >"$work/log" 2>&1
}
# ── 1. Silence unless asked ─────────────────────────────────────────────────
mkdir -p "$home/.claude"
printf 'somebody else instructions\n' >"$home/.claude/CLAUDE.md"
run
[[ "$(cat "$home/.claude/CLAUDE.md")" == "somebody else instructions" ]] \
|| note 'personal content was linked on a machine that was never asked'
[[ -e "$home/.agents/skills" ]] \
&& note 'skills were linked on a machine that was never asked'
PANAMA_USER_CONTENT=no run
[[ "$(cat "$home/.claude/CLAUDE.md")" == "somebody else instructions" ]] \
|| note 'personal content was linked on a machine that answered no'
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
# ~/.claude/skills is shared now: link-skills has already made it a real
# directory and linked Panama's own skills into it. A linkdir entry has to land
# beside those rather than replace the directory holding them.
mkdir -p "$home/.claude/skills"
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
PANAMA_USER_CONTENT=yes run
[[ -L "$home/.claude/CLAUDE.md" ]] \
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|| note 'the CLAUDE.md link does not resolve to the tracked file'
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|| note 'the skills directory was not linked as a directory'
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|| note 'a linkdir entry replaced its destination directory with a symlink'
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|| note 'a linkdir entry did not link each child into the destination'
[[ -L "$home/.claude/skills/shipped" ]] \
|| note 'a linkdir entry removed what another stage had linked into the destination'
[[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \
|| note 'a copy entry was linked rather than copied'
if ! grep -rq 'somebody else instructions' "$checkout/config/old" 2>/dev/null; then
note 'the file that was already there was not preserved in config/old'
fi
# A source the manifest names but the checkout does not have is skipped, not
# fatal: half a manifest applied is better than none, and the log says which.
[[ -e "$home/.config/missing.txt" ]] \
&& note 'a manifest entry with no source produced a destination anyway'
grep -q 'missing.txt' "$work/log" \
|| note 'a skipped manifest entry was not reported'
# ── The answer sticks ───────────────────────────────────────────────────────
#
# So that `panama upgrade` on a configured machine relinks without an interview,
# and a machine that said no stays quiet forever.
printf 'edited by hand\n' >"$home/.config/plain.txt"
rm -f "$home/.claude/CLAUDE.md"
run # no PANAMA_USER_CONTENT this time
[[ -L "$home/.claude/CLAUDE.md" ]] \
|| note 'the recorded answer was not remembered, so an upgrade would not relink'
[[ "$(cat "$home/.config/plain.txt")" == "edited by hand" ]] \
|| note 'a copy entry overwrote a file the machine had edited'
# Relinking twice must not bury the previous run's symlink in config/old.
before="$(find "$checkout/config/old" -type f 2>/dev/null | wc -l)"
run
after="$(find "$checkout/config/old" -type f 2>/dev/null | wc -l)"
[[ "$before" == "$after" ]] \
|| note 'relinking backed up its own symlink, so config/old grows every run'
# ── Wiring ──────────────────────────────────────────────────────────────────
grep -q 'link-user' "$installer" \
|| note 'link-user is not in the installer STAGES list, so it never runs'
grep -q 'PANAMA_USER_CONTENT' "$installer" \
|| note 'the installer does not export the personal-content answer'
grep -q 'PANAMA_USER_CONTENT' "$interview" \
|| note 'the interview never asks about personal content'
# Order matters: link-user must land the tracked espanso identity before
# setup-identity would seed one from the interview answers.
python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
stages = re.findall(r"[\w-]+", line)
if stages.index("link-user") > stages.index("setup-identity"):
raise SystemExit(1)
PY
if (( ${#findings[@]} > 0 )); then
printf 'user content contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'user content contract: PASS\n'