197 lines
9.4 KiB
Bash
Executable File
197 lines
9.4 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
|
|
linkdir 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 ───────────────────────────
|
|
|
|
# Both skill homes are shared now: link-skills has already made them real
|
|
# directories and linked Panama's own skills into each. Personal linkdir
|
|
# entries land beside those rather than replacing either directory.
|
|
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
|
|
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
|
ln -s "$checkout/skills/shipped" "$home/.agents/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'
|
|
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
|
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink'
|
|
[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
|
|| note 'a linkdir entry did not publish the personal skill for agent-neutral readers'
|
|
[[ -L "$home/.agents/skills/shipped" ]] \
|
|
|| note 'a linkdir entry removed the shipped skill from ~/.agents/skills'
|
|
[[ -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. Checked in every
|
|
# role's literal stage list (the upgrade filter reassigns from a variable and
|
|
# is not a list).
|
|
python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity'
|
|
import re, sys
|
|
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
|
|
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
|
|
if not lines:
|
|
raise SystemExit(1)
|
|
for line in lines:
|
|
stages = re.findall(r"[\w-]+", line)
|
|
if "link-user" in stages and "setup-identity" in stages:
|
|
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'
|