From 60491285fd2237252497e3a88d33c947e1233857 Mon Sep 17 00:00:00 2001 From: Ruslan Korneev Date: Mon, 27 Jul 2026 17:22:26 +0200 Subject: [PATCH] feat(install): upgrade an install without clobbering what the project changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core is copied into each project byte-for-byte, so a change here reached them only through a manual `--force` per project — and `--force` cannot tell a stale copy from one the project edited, so the choice was between overwriting local fixes and never updating at all. With one project that is invisible. At five it means five versions of the loop running under one name, and a harness-improver proposal applied here reaching none of them. install.sh upgrade [--dry-run] [--force] install.sh upgrade --all [--dry-run] install.sh installs upgrade re-applies the shape the project was installed with — stack, tools, skills, agents, scripts, hooks — read back from .ai-tooling.json. Changing that shape stays a re-install, deliberately. The distinction that makes it safe is a hash per installed file, recorded in the manifest at write time. Each destination is then one of four things: current identical to canonical — nothing to do stale differs from canonical, matches the recorded hash → nobody here touched it → replaced modified differs from both → this project edited it → reported and KEPT unknown no recorded hash (pre-2.3.0 manifest, or a file install skipped) → treated as modified A kept file carries its OLD hash into the new manifest. Re-hashing a file we deliberately did not write would record the local edit as ours, and the next upgrade would read it as merely stale and overwrite it — losing the change the previous run protected. Caught by running upgrade three times in a row against an edited file; the test asserts the edit survives all three. Exit is non-zero while any modified file is outstanding: an upgrade that left files behind is not fully applied. --force overwrites them and exits 0. - A registry (~/.config/ai-tooling/installs, ADW_REGISTRY to relocate) is appended at install time so `--all` has something to iterate. A path that has gone away is reported and skipped, never removed behind the operator's back. - The manifest gains files{} plus the agents/scripts/rules flags, and is written by python3 rather than printf — a hash map is not worth hand-rolling JSON for. - doctor reports installed vs canonical version and says BEHIND with the command to fix it. - file_hash degrades across shasum → sha256sum → openssl; a machine with none of them reports every differing file as modified rather than guessing. VERSION 2.2.0 → 2.3.0, and the discipline that goes with it: the version moves with every core change, because that is what lets doctor and installs tell which projects are behind. Tests: 27 new assertions in tests/cases/install-upgrade.sh, driving a real install into a throwaway repo from a throwaway copy of this checkout, then moving that copy ahead. Covers the manifest shape, the registry, the four classifications, the dry run writing nothing, the three-upgrade regression above, --force, and four misuse paths. ADW_REGISTRY keeps the operator's real registry out of it. Also widens the portability rule: comments and double-quoted strings are stripped before scanning for GNU-only tools, and a line guarded by `command -v` is compliant — `file_hash` names sha256sum in both a guard and a warning string, and neither is a portability defect. The blind spot this accepts (a call hidden inside a quoted command string) is written down in the test. Suite: 143 passed, 0 failed, 1 known gap, under bash 5.2 and 3.2. Refs RUS-192 --- README.md | 26 ++- install.sh | 287 ++++++++++++++++++++++++++++++++- tests/cases/install-upgrade.sh | 81 ++++++++++ tests/cases/portability.sh | 10 +- tests/lib/harness.sh | 10 +- tests/run.sh | 3 +- 6 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 tests/cases/install-upgrade.sh diff --git a/README.md b/README.md index 04c2a52..bf0eaf9 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,34 @@ configured with a command that errors reports `SKIPPED` while the loop looks gre bash ~/.config/ai-tooling/install.sh install /path/to/project # stack auto-detected bash ~/.config/ai-tooling/install.sh install . --stack python bash ~/.config/ai-tooling/install.sh install . --no-hooks # skip the on-edit format hook -bash ~/.config/ai-tooling/install.sh doctor . # drift, unset commands, engines +bash ~/.config/ai-tooling/install.sh doctor . # version delta, drift, unset commands bash ~/.config/ai-tooling/install.sh uninstall . # removes generated copies, keeps .tasks/ ``` +## Upgrading an install + +The core is copied into each project, so a change here does not reach them on its own. `upgrade` takes +them forward: + +```bash +bash ~/.config/ai-tooling/install.sh upgrade /path/to/project # one project +bash ~/.config/ai-tooling/install.sh upgrade /path/to/project --dry-run # classify, write nothing +bash ~/.config/ai-tooling/install.sh upgrade --all # every project ever installed +bash ~/.config/ai-tooling/install.sh installs # which ones, and at what version +``` + +It re-applies the shape the project was installed with — stack, tools, skills, hooks — read back from +`.ai-tooling.json`. Changing that shape is a re-install, deliberately. + +The distinction that makes it safe: the manifest records a **hash per installed file**, so an upgrade +can tell a copy that is merely out of date from one this project edited. Untouched copies are replaced +silently; an edited one is reported and **kept**, and stays kept across later upgrades, until you pass +`--force`. An edited core file is either a local fix worth upstreaming or drift worth discarding, and +only you know which — so the exit code is non-zero while any are outstanding. + +`.tasks/_STACK.md` is never regenerated. `VERSION` in `install.sh` moves with every core change, which +is what lets `doctor` and `installs` report which projects are behind. + Then, once per project: ```bash diff --git a/install.sh b/install.sh index c96be20..08debab 100755 --- a/install.sh +++ b/install.sh @@ -2,11 +2,19 @@ # ai-tooling — install a portable AI Developer Workflow (skills + agents + gates) into a project. # # bash ~/.config/ai-tooling/install.sh install [options] +# bash ~/.config/ai-tooling/install.sh upgrade [--dry-run] [--force] +# bash ~/.config/ai-tooling/install.sh upgrade --all [--dry-run] +# bash ~/.config/ai-tooling/install.sh installs # every project installed from this checkout # bash ~/.config/ai-tooling/install.sh list # bash ~/.config/ai-tooling/install.sh doctor # bash ~/.config/ai-tooling/install.sh uninstall # bash ~/.config/ai-tooling/install.sh self-install # put the /adw-install skill in ~/.claude/skills # +# `upgrade` re-copies the core using the options the project was installed with, and knows the +# difference between a file that is merely out of date and one the project edited: the manifest records +# a hash per installed file, so an untouched copy is replaced silently and a modified one is reported +# and left alone until you pass --force. +# # Options for `install`: # --stack stack profile from stacks/ (auto-detected when omitted) # --skills a,b,c install only these skill ids (default: all) @@ -25,12 +33,33 @@ set -uo pipefail SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VERSION="2.2.0" +VERSION="2.3.0" SCRIPT_FILES="lib.sh gate.sh guard.sh engines.sh intake.sh review.sh setup-worktree.sh worktree-alloc.sh" +REGISTRY="${ADW_REGISTRY:-$HOME/.config/ai-tooling/installs}" + die() { echo "[ai-tooling] ERROR: $*" >&2; exit 1; } info() { echo "[ai-tooling] $*" >&2; } +# Content hash of one file. Every platform has one of these three; none of them is guaranteed, so a +# machine with no hasher degrades to "differs → ask" rather than silently overwriting. +file_hash() { + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}' + else return 1; fi +} +have_hasher() { file_hash "${BASH_SOURCE[0]}" >/dev/null 2>&1; } + +# Every install appends itself here so `upgrade --all` has something to iterate. One line per path, +# deduplicated; a path that no longer exists is reported and skipped, never removed behind your back. +register_install() { + local path="$1" + mkdir -p "$(dirname "$REGISTRY")" + [[ -f "$REGISTRY" ]] && grep -qxF "$path" "$REGISTRY" && return 0 + printf '%s\n' "$path" >> "$REGISTRY" +} + all_skills() { ls -1 "$SRC/skills/core" 2>/dev/null; } all_agents() { ls -1 "$SRC/agents" 2>/dev/null | sed 's/\.md$//'; } all_stacks() { ls -1 "$SRC/stacks" 2>/dev/null | sed 's/\.stack$//'; } @@ -87,6 +116,67 @@ copy_file() { fi [[ "$DRY_RUN" == 1 ]] && { info "would write: ${dest#$TARGET/}"; return 0; } mkdir -p "$(dirname "$dest")"; cp "$src" "$dest" + # Recorded so a later `upgrade` can tell "out of date" from "the project edited this". + WROTE_FILES+=("${dest#$TARGET/}") +} + +# The manifest is what `upgrade` reads: the options this project was installed with, plus a hash of +# every file we wrote. Without the hashes an upgrade cannot tell a stale copy from an edited one, and +# has to choose between clobbering the project's changes and refusing to move at all. +write_manifest() { + local skills_csv; skills_csv="$(printf '%s,' "$@" | sed 's/,$//')" + # Hashes carried over from an upgrade that deliberately did NOT write a file come first; a fresh hash + # of a file we wrote overrides them. + local hashes="${KEEP_HASHES:-}" f h + if have_hasher; then + for f in ${WROTE_FILES[@]+"${WROTE_FILES[@]}"}; do + h="$(file_hash "$TARGET/$f")" || continue + hashes="$hashes$f $h +" + done + fi + ADW_VERSION="$VERSION" ADW_SRC="$SRC" ADW_STACK="$STACK" ADW_TOOLS="$TOOLS" \ + ADW_HOOKS="$([[ $WITH_HOOKS == 1 ]] && echo true || echo false)" \ + ADW_AGENTS="$([[ $WITH_AGENTS == 1 ]] && echo true || echo false)" \ + ADW_SCRIPTS="$([[ $WITH_SCRIPTS == 1 ]] && echo true || echo false)" \ + ADW_RULES="$([[ $WITH_RULES == 1 ]] && echo true || echo false)" \ + ADW_SKILLS="$skills_csv" ADW_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" ADW_HASHES="$hashes" \ + python3 - "$TARGET/.ai-tooling.json" <<'PY' +import json, os, sys +files = {} +for line in os.environ.get("ADW_HASHES", "").splitlines(): + if "\t" in line: + path, h = line.split("\t", 1) + files[path] = h +doc = { + "version": os.environ["ADW_VERSION"], + "source": os.environ["ADW_SRC"], + "stack": os.environ["ADW_STACK"], + "tools": os.environ["ADW_TOOLS"], + "hooks": os.environ["ADW_HOOKS"] == "true", + "agents": os.environ["ADW_AGENTS"] == "true", + "scripts": os.environ["ADW_SCRIPTS"] == "true", + "rules": os.environ["ADW_RULES"] == "true", + "installedAt": os.environ["ADW_AT"], + "skills": [s for s in os.environ["ADW_SKILLS"].split(",") if s], + "files": files, +} +with open(sys.argv[1], "w") as fh: + json.dump(doc, fh, indent=2, sort_keys=True) + fh.write("\n") +PY +} + +manifest_get() { # — one scalar, or a csv for a list. Empty when absent. + python3 - "$1/.ai-tooling.json" "$2" <<'PY' 2>/dev/null +import json, sys +try: + doc = json.load(open(sys.argv[1])) +except Exception: + sys.exit(0) +v = doc.get(sys.argv[2], "") +print(",".join(v) if isinstance(v, list) else ("true" if v is True else "false" if v is False else v)) +PY } seed_file() { @@ -242,6 +332,7 @@ cmd_install() { STACK=""; SKILLS=""; TOOLS="claude,codex" WITH_AGENTS=1; WITH_SCRIPTS=1; WITH_RULES=1; WITH_HOOKS=1; FORCE=0; DRY_RUN=0; REGEN_STACK=0 + WROTE_FILES=(); KEEP_HASHES="" while (( $# )); do case "$1" in @@ -307,10 +398,8 @@ cmd_install() { (( WITH_HOOKS )) && install_hooks if [[ "$DRY_RUN" != 1 ]]; then - printf '{\n "version": "%s",\n "source": "%s",\n "stack": "%s",\n "tools": "%s",\n "hooks": %s,\n "installedAt": "%s",\n "skills": [%s]\n}\n' \ - "$VERSION" "$SRC" "$STACK" "$TOOLS" "$([[ $WITH_HOOKS == 1 ]] && echo true || echo false)" \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(printf '"%s",' "${installed[@]}" | sed 's/,$//')" \ - > "$TARGET/.ai-tooling.json" + write_manifest "${installed[@]}" + register_install "$TARGET" fi # Files the installer writes can be silently swallowed by a global gitignore: the .codex copies get @@ -341,11 +430,191 @@ cmd_install() { EOF } +# Classify one destination file against the new canonical and against what we last wrote there. +# current — already identical to canonical, nothing to do +# stale — differs from canonical, matches the hash we recorded → the project never touched it +# modified — differs from both → somebody edited it here; that edit is theirs to keep or discard +# unknown — no recorded hash (older manifest, or a file install skipped) → treated as modified +classify_file() { + local src="$1" dest="$2" recorded="$3" + [[ -f "$dest" ]] || { printf 'missing'; return; } + cmp -s "$src" "$dest" && { printf 'current'; return; } + [[ -n "$recorded" ]] || { printf 'unknown'; return; } + local now; now="$(file_hash "$dest")" || { printf 'unknown'; return; } + [[ "$now" == "$recorded" ]] && printf 'stale' || printf 'modified' +} + +cmd_upgrade() { + local first="${1:-}"; [[ -n "$first" ]] || die "usage: upgrade [--dry-run] [--force] | upgrade --all" + if [[ "$first" == "--all" ]]; then shift; cmd_upgrade_all "$@"; return $?; fi + + TARGET="$(cd "$first" 2>/dev/null && pwd)" || die "no such directory: $first" + shift + FORCE=0; DRY_RUN=0; REGEN_STACK=0; WROTE_FILES=(); KEEP_HASHES="" + while (( $# )); do + case "$1" in + --force) FORCE=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + *) die "unknown option: $1 (upgrade takes --dry-run and --force)" ;; + esac + done + + [[ -f "$TARGET/.ai-tooling.json" ]] \ + || die "no .ai-tooling.json in $TARGET — this project was never installed from here. Use: install $TARGET" + + local from; from="$(manifest_get "$TARGET" version)" + info "upgrade: $TARGET" + info " ${from:-unknown} → $VERSION (source: $SRC)" + [[ "$from" == "$VERSION" ]] && info " already at $VERSION — re-copying anyway, in case the checkout moved ahead of its own version" + have_hasher || info " WARN: no shasum/sha256sum/openssl here. Cannot tell a stale copy from an edited one;" + have_hasher || info " every differing file will be reported as modified and left alone." + + # The shape of the install is the project's decision, made at install time. An upgrade re-applies the + # same shape; changing it (adding codex, dropping hooks) is a re-install, deliberately. + STACK="$(manifest_get "$TARGET" stack)"; [[ -n "$STACK" ]] || STACK="$(detect_stack "$TARGET")" + TOOLS="$(manifest_get "$TARGET" tools)"; [[ -n "$TOOLS" ]] || TOOLS="claude,codex" + SKILLS="$(manifest_get "$TARGET" skills)" + [[ -n "$SKILLS" ]] || SKILLS="$(all_skills | tr '\n' ',' | sed 's/,$//')" + WITH_HOOKS=1; [[ "$(manifest_get "$TARGET" hooks)" == "false" ]] && WITH_HOOKS=0 + WITH_AGENTS=1; [[ "$(manifest_get "$TARGET" agents)" == "false" ]] && WITH_AGENTS=0 + WITH_SCRIPTS=1; [[ "$(manifest_get "$TARGET" scripts)" == "false" ]] && WITH_SCRIPTS=0 + WITH_RULES=1; [[ "$(manifest_get "$TARGET" rules)" == "false" ]] && WITH_RULES=0 + + local recorded; recorded="$(python3 - "$TARGET/.ai-tooling.json" <<'PY' 2>/dev/null +import json, sys +try: + doc = json.load(open(sys.argv[1])) +except Exception: + sys.exit(0) +for path, h in (doc.get("files") or {}).items(): + print("%s\t%s" % (path, h)) +PY +)" + hash_of() { printf '%s\n' "$recorded" | awk -F'\t' -v p="$1" '$1==p {print $2; exit}'; } + + # Build the canonical list: source path destination path, exactly as install would write it. + local plan="" id tool a f rel + IFS=',' read -ra _skills <<< "$SKILLS" + IFS=',' read -ra _tools <<< "$TOOLS" + for id in "${_skills[@]}"; do + id="$(printf '%s' "$id" | tr -d ' ')" + [[ -d "$SRC/skills/core/$id" ]] || { info " GONE skill '$id' no longer exists in the source — left in place"; continue; } + for tool in "${_tools[@]}"; do + case "$tool" in claude) rel=".claude/skills/$id" ;; codex) rel=".codex/skills/$id" ;; *) continue ;; esac + while IFS= read -r f; do plan="$plan$SRC/skills/core/$id/$f $rel/$f +"; done < <(cd "$SRC/skills/core/$id" && find . -type f | sed 's|^\./||') + done + done + (( WITH_AGENTS )) && for a in $(all_agents); do plan="$plan$SRC/agents/$a.md .claude/agents/$a.md +"; done + (( WITH_SCRIPTS )) && for f in $SCRIPT_FILES; do plan="$plan$SRC/scripts/$f scripts/ai/$f +"; done + plan="$plan$SRC/templates/_dev-prompt-template.md .tasks/_dev-prompt-template.md +$SRC/templates/GROOM_LOG.md .tasks/_templates/GROOM_LOG.md +" + + local n_current=0 n_stale=0 n_modified=0 n_missing=0 modified_list="" verdict src dest + while IFS=' ' read -r src dest; do + [[ -n "$dest" ]] || continue + verdict="$(classify_file "$src" "$TARGET/$dest" "$(hash_of "$dest")")" + case "$verdict" in + current) n_current=$(( n_current + 1 )); WROTE_FILES+=("$dest") ;; + missing) n_missing=$(( n_missing + 1 )) + [[ "$DRY_RUN" == 1 ]] && info " ADD $dest" || { mkdir -p "$TARGET/$(dirname "$dest")"; cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest"); } ;; + stale) n_stale=$(( n_stale + 1 )) + [[ "$DRY_RUN" == 1 ]] && info " UPDATE $dest" || { cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest"); } ;; + *) n_modified=$(( n_modified + 1 )); modified_list="$modified_list $dest +" + if (( FORCE )) && [[ "$DRY_RUN" != 1 ]]; then + cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest") + else + # Carry the OLD hash forward. Re-hashing a file we chose not to write would record the + # local edit as ours, and the next upgrade would read it as merely stale and clobber + # it — losing exactly the edit this branch exists to protect. + KEEP_HASHES="$KEEP_HASHES$dest $(hash_of "$dest") +" + fi + if [[ "$DRY_RUN" == 1 ]]; then + info " MODIFIED $dest $( (( FORCE )) && echo '(--force would overwrite)' || echo '(kept)')" + fi ;; + esac + done <<< "$plan" + + [[ "$DRY_RUN" != 1 && "$WITH_SCRIPTS" == 1 ]] && chmod +x "$TARGET"/scripts/ai/*.sh 2>/dev/null + info " $n_current current · $n_stale updated · $n_missing added · $n_modified modified here" + if (( n_modified )); then + if (( FORCE )); then + info " --force: the local edits above were overwritten." + else + info " Kept as they are — an edited core file is either a local fix worth upstreaming or drift" + info " worth discarding, and only you know which:" + printf '%s' "$modified_list" >&2 + info " Overwrite them with: install.sh upgrade $TARGET --force" + fi + fi + + if [[ "$DRY_RUN" == 1 ]]; then info " DRY RUN — nothing was written"; return 0; fi + + # .tasks/_STACK.md is never regenerated: it is the project's own configuration. + (( WITH_RULES )) && install_rules + (( WITH_HOOKS )) && install_hooks + + IFS=',' read -ra _installed <<< "$SKILLS" + write_manifest "${_installed[@]}" + register_install "$TARGET" + info " manifest updated → .ai-tooling.json (version $VERSION)" + # Files left behind means the upgrade is not fully applied; --force leaves nothing behind. + (( n_modified )) && (( ! FORCE )) && return 1 + return 0 +} + +cmd_upgrade_all() { + local args=("$@") + [[ -f "$REGISTRY" ]] || die "no installs recorded yet ($REGISTRY). Install somewhere first, or upgrade one path by name." + local path rc=0 seen=0 + while IFS= read -r path; do + [[ -n "$path" ]] || continue + seen=$(( seen + 1 )) + if [[ ! -f "$path/.ai-tooling.json" ]]; then + info "SKIP $path — gone, moved, or uninstalled (still listed in $REGISTRY)"; continue + fi + echo >&2 + cmd_upgrade "$path" ${args[@]+"${args[@]}"} || rc=1 + done < "$REGISTRY" + echo >&2 + info "upgrade --all: $seen path(s) in the registry" + (( rc )) && info "at least one project has locally modified core files — see above" + return $rc +} + +cmd_installs() { + [[ -f "$REGISTRY" ]] || { echo "no installs recorded ($REGISTRY)"; return 0; } + echo "ai-tooling installs — canonical $VERSION at $SRC" + local path v + while IFS= read -r path; do + [[ -n "$path" ]] || continue + if [[ ! -f "$path/.ai-tooling.json" ]]; then printf ' %-52s %s\n' "$path" "GONE"; continue; fi + v="$(manifest_get "$path" version)" + printf ' %-52s %s%s\n' "$path" "${v:-unknown}" \ + "$([[ "$v" == "$VERSION" ]] && echo "" || echo " ← differs from canonical")" + done < "$REGISTRY" +} + cmd_doctor() { local target="${1:-}"; [[ -n "$target" ]] || die "usage: doctor " target="$(cd "$target" && pwd)" echo "ai-tooling doctor — $target" - [[ -f "$target/.ai-tooling.json" ]] && cat "$target/.ai-tooling.json" || echo " (never installed here)" + if [[ -f "$target/.ai-tooling.json" ]]; then + local iv; iv="$(manifest_get "$target" version)" + echo " installed: ${iv:-unknown} canonical: $VERSION ($SRC)" + if [[ -n "$iv" && "$iv" != "$VERSION" ]]; then + echo " BEHIND — bring it forward with: install.sh upgrade $target" + fi + echo " stack: $(manifest_get "$target" stack) tools: $(manifest_get "$target" tools) hooks: $(manifest_get "$target" hooks)" + echo " installed at: $(manifest_get "$target" installedAt)" + else + echo " (never installed here)" + fi echo local drift=0 for id in $(all_skills); do @@ -431,10 +700,12 @@ cmd_uninstall() { case "${1:-}" in install) shift; cmd_install "$@" ;; + upgrade) shift; cmd_upgrade "$@" ;; + installs) cmd_installs ;; self-install) cmd_self_install ;; list|"") cmd_list ;; doctor) shift; cmd_doctor "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; - -h|--help) sed -n '2,24p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;; - *) die "unknown command: $1 (install|self-install|list|doctor|uninstall)" ;; + -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;; + *) die "unknown command: $1 (install|upgrade|installs|self-install|list|doctor|uninstall)" ;; esac diff --git a/tests/cases/install-upgrade.sh b/tests/cases/install-upgrade.sh new file mode 100644 index 0000000..1665346 --- /dev/null +++ b/tests/cases/install-upgrade.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# install.sh upgrade — the point of it is one distinction: a core file that is merely out of date +# versus one this project edited. Get it wrong in one direction and upgrades clobber local fixes; get +# it wrong in the other and nothing can ever be updated without a per-file argument. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" + +# Never touch the operator's real registry. +export ADW_REGISTRY="$(tmpdir registry)/installs" + +target="$(fixture target)" || exit 1 +rm -rf "$target/scripts" # this test installs the scripts itself + +installer() { local src="$1"; shift; bash "$src/install.sh" "$@"; } + +# A copy of the canonical checkout we can move ahead of the target, the way a real repo does. +canon="$(tmpdir canon)" +cp -R "$ADW_ROOT"/agents "$ADW_ROOT"/bootstrap "$ADW_ROOT"/scripts "$ADW_ROOT"/skills \ + "$ADW_ROOT"/stacks "$ADW_ROOT"/templates "$ADW_ROOT"/install.sh "$canon/" + +check 'install into a fresh project' 0 installer "$canon" install "$target" --tools claude --no-hooks + +manifest_field() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2]))" \ + "$target/.ai-tooling.json" "$1"; } +hash_for() { python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['files'].get(sys.argv[2],'MISSING'))" \ + "$target/.ai-tooling.json" "$1"; } + +check_out 'the manifest records the shape it was installed with' 'claude' manifest_field tools +check_empty 'and not the tool it was told to skip' \ + bash -c "python3 -c \"import json;print(json.load(open('$target/.ai-tooling.json'))['tools'])\" | grep codex || true" +canonical_hash() { python3 -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; } +check_out 'a hash is recorded for a file it wrote' "$(canonical_hash "$ADW_ROOT/scripts/gate.sh")" \ + hash_for scripts/ai/gate.sh +check_out 'the install registers itself' "$target" cat "$ADW_REGISTRY" +check_out 'installs lists it' "$target" installer "$canon" installs + +# ── nothing moved: an upgrade is a no-op that says so ───────────────────────── +check 'upgrade with an unchanged source → 0' 0 installer "$canon" upgrade "$target" +check_out 'and reports everything as current' '0 updated' installer "$canon" upgrade "$target" + +# ── the canonical moves ahead, and the project edits one file of its own ────── +printf '\n# the canonical moved ahead\n' >> "$canon/scripts/lib.sh" +printf '\n# a local fix nobody upstreamed\n' >> "$target/scripts/ai/guard.sh" + +check_out 'dry-run: the untouched-but-stale file is an UPDATE' 'UPDATE scripts/ai/lib.sh' \ + installer "$canon" upgrade "$target" --dry-run +check_out 'dry-run: the edited file is MODIFIED, and kept' 'MODIFIED scripts/ai/guard.sh' \ + installer "$canon" upgrade "$target" --dry-run +check_out 'dry-run writes nothing' 'nothing was written' installer "$canon" upgrade "$target" --dry-run +check_out 'so the stale file is still stale afterwards' '' \ + bash -c "grep -c 'canonical moved ahead' '$target/scripts/ai/lib.sh' || true" + +# ── apply ───────────────────────────────────────────────────────────────────── +check 'an upgrade that left files behind exits non-zero' 1 installer "$canon" upgrade "$target" +check_out 'the stale file was updated' 'canonical moved ahead' cat "$target/scripts/ai/lib.sh" +check_out 'the local fix was kept' 'a local fix nobody upstreamed' cat "$target/scripts/ai/guard.sh" +check_out 'and the version moved' "$(grep -m1 '^VERSION=' "$canon/install.sh" | cut -d'"' -f2)" \ + manifest_field version + +# The regression that matters: after an upgrade that KEPT a file, the manifest must still hold the old +# hash. Recording the local edit as ours would make the next upgrade read it as merely stale and +# overwrite it — losing the change the previous run deliberately protected. +check 'the second upgrade still calls it modified' 1 installer "$canon" upgrade "$target" +check_out 'and the local fix is still there' 'a local fix nobody upstreamed' cat "$target/scripts/ai/guard.sh" +check 'and a third' 1 installer "$canon" upgrade "$target" +check_out 'still there' 'a local fix nobody upstreamed' cat "$target/scripts/ai/guard.sh" + +# ── --force is the explicit way to discard a local edit ─────────────────────── +check '--force overwrites and exits 0' 0 installer "$canon" upgrade "$target" --force +check_empty 'the local fix is gone' \ + bash -c "grep 'a local fix nobody upstreamed' '$target/scripts/ai/guard.sh' || true" +check 'and the next upgrade is a clean no-op' 0 installer "$canon" upgrade "$target" + +# ── misuse ──────────────────────────────────────────────────────────────────── +never="$(tmpdir never-installed)" +check 'upgrading a project that was never installed → error' 1 installer "$canon" upgrade "$never" +check_out 'and it says to install instead' 'was never installed' installer "$canon" upgrade "$never" +check 'unknown option → error' 1 installer "$canon" upgrade "$target" --regen-stack +check 'no target at all → error' 1 installer "$canon" upgrade + +done_tests diff --git a/tests/cases/portability.sh b/tests/cases/portability.sh index a2fecb0..7c711db 100755 --- a/tests/cases/portability.sh +++ b/tests/cases/portability.sh @@ -34,10 +34,16 @@ check_xfail 'core scripts avoid bash-4-only constructs' 0 bash4_free # GNU-only tools are the other half of the same problem: absent from a stock macOS, so a script that # reaches for one works on CI and dies on the operator's laptop. `timeout` is not in this list on # purpose — engines.sh implements its own `with_timeout` rather than shelling out to coreutils. +# Comments and double-quoted strings are stripped first, so naming a tool in a warning ("no +# shasum/sha256sum/openssl here") is not a use of it. A line that tests for the tool with `command -v` +# before calling it is the documented way to reach for one anyway — it degrades with a message instead +# of dying — so those lines are compliant too. +# Blind spot, accepted: a genuine call hidden inside a double-quoted command string is invisible here. gnu_free() { local hits - hits="$(grep -nE '(^|[^[:alnum:]_-])(realpath|sha256sum|tac|readlink -f|date -d|stat -c|grep -P|xargs -r)([^[:alnum:]_-]|$)' \ - "$ADW_ROOT"/scripts/*.sh "$ADW_ROOT"/install.sh)" || return 0 + hits="$(sed -E 's/"[^"]*"//g; s/#.*$//' "$ADW_ROOT"/scripts/*.sh "$ADW_ROOT"/install.sh \ + | grep -nE '(^|[^[:alnum:]_-])(realpath|sha256sum|tac|readlink -f|date -d|stat -c|grep -P|xargs -r)([^[:alnum:]_-]|$)' \ + | grep -v 'command -v')" || return 0 printf '%s\n' "$hits" return 1 } diff --git a/tests/lib/harness.sh b/tests/lib/harness.sh index 974b02e..605f0a1 100644 --- a/tests/lib/harness.sh +++ b/tests/lib/harness.sh @@ -35,7 +35,7 @@ trap _cleanup EXIT fixture() { local name="${1:?usage: fixture }" dir - [[ -n "$_tmp_root" ]] || _tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/adw-tests.XXXXXX")" + [[ -n "$_tmp_root" ]] || _tmp_root="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/adw-tests.XXXXXX")" && pwd)" dir="$_tmp_root/$name" mkdir -p "$dir/scripts/ai" "$dir/.tasks" cp "$ADW_ROOT"/scripts/*.sh "$dir/scripts/ai/" @@ -63,6 +63,14 @@ task() { mkdir -p "$dir/.tasks/$id" } +# A bare directory under the same throwaway root — for tests that need somewhere that is not a repo. +tmpdir() { + local name="${1:?usage: tmpdir }" + [[ -n "$_tmp_root" ]] || _tmp_root="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/adw-tests.XXXXXX")" && pwd)" + mkdir -p "$_tmp_root/$name" + printf '%s' "$_tmp_root/$name" +} + commit_all() { ( cd "$1" && git add -A && git commit -qm "${2:-step}" ) >/dev/null 2>&1; } git_in() { local d="$1"; shift; ( cd "$d" && git "$@" ) >/dev/null 2>&1; } write() { mkdir -p "$(dirname "$1")" && printf '%s\n' "${2:-x}" > "$1"; } diff --git a/tests/run.sh b/tests/run.sh index 2fa5d12..7c4b9d1 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -42,7 +42,8 @@ done cat <<'NOTE' not covered (needs a remote, gh, or a live engine CLI): gate.sh workspace · gate.sh ready · gate.sh committed (upstream branch) - review.sh fan-out · engines.sh probe/run · intake.sh fetch/writeback · install.sh + review.sh fan-out · engines.sh probe/run · intake.sh fetch/writeback + install.sh: install + upgrade are covered; doctor, uninstall and hook registration are not NOTE if (( ${#failed[@]} )); then