Plan each sync before writing, record the plan in .ai-config-transaction.json, and roll an interrupted destination back from its backups on the next run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
15 KiB
Bash
199 lines
15 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
summary=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--summary) summary=true; shift ;;
|
|
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
. "$root/scripts/lib/manifest.sh"
|
|
. "$root/scripts/lib/install-targets.sh"
|
|
|
|
work=$(mktemp -d)
|
|
cleanup() { rm -rf -- "$work"; }
|
|
trap cleanup EXIT
|
|
|
|
source_dir="$work/source"
|
|
destination="$work/destination"
|
|
mkdir -p "$source_dir"
|
|
printf 'A' > "$source_dir/a.md"
|
|
printf 'B' > "$source_dir/b.md"
|
|
printf 'C' > "$source_dir/c.md"
|
|
|
|
out1=$(sync_managed_destination "$source_dir" "$destination" run1 "$destination" bash bash false)
|
|
[ "$(printf '%s\n' "$out1" | grep -c '^CREATE')" -eq 3 ] || { printf 'Expected three CREATE lines on first install:\n%s\n' "$out1" >&2; exit 1; }
|
|
|
|
out2=$(sync_managed_destination "$source_dir" "$destination" run2 "$destination" bash bash false)
|
|
[ -z "$(printf '%s\n' "$out2" | grep -v '^UNCHANGED' | grep -v '^SOURCE ')" ] || { printf 'Second identical install was not a no-op:\n%s\n' "$out2" >&2; exit 1; }
|
|
[ ! -d "$destination/backups/run2" ] || { printf 'Idempotent install must not create a backup.\n' >&2; exit 1; }
|
|
|
|
printf 'root=__AI_CONFIG_ROOT__\n\n' > "$source_dir/newlines.txt"
|
|
printf 'root=%s\n\n' "$destination" > "$work/expected-newlines.txt"
|
|
sync_managed_destination "$source_dir" "$destination" newlines "$destination" bash bash false > /dev/null
|
|
cmp -s "$work/expected-newlines.txt" "$destination/newlines.txt" || { printf 'Substitution changed terminal newlines.\n' >&2; exit 1; }
|
|
|
|
printf 'mine' > "$destination/foreign.md"
|
|
|
|
rm -f "$source_dir/b.md" "$source_dir/c.md"
|
|
printf 'locally changed' > "$destination/c.md"
|
|
|
|
out3=$(sync_managed_destination "$source_dir" "$destination" run3 "$destination" bash bash false)
|
|
printf '%s\n' "$out3" | grep -q '^REMOVE .*b\.md$' || { printf 'Expected b.md to be removed:\n%s\n' "$out3" >&2; exit 1; }
|
|
[ ! -f "$destination/b.md" ] || { printf 'b.md should have been removed.\n' >&2; exit 1; }
|
|
printf '%s\n' "$out3" | grep -q 'WARN .*c\.md.*changed locally' || { printf 'Expected c.md to be flagged as locally modified:\n%s\n' "$out3" >&2; exit 1; }
|
|
[ -f "$destination/c.md" ] || { printf 'A locally modified stale file must not be removed.\n' >&2; exit 1; }
|
|
[ "$(cat "$destination/c.md")" = 'locally changed' ] || { printf 'c.md content must be preserved exactly.\n' >&2; exit 1; }
|
|
[ "$(cat "$destination/foreign.md")" = 'mine' ] || { printf 'A file this setup never installed must never be touched.\n' >&2; exit 1; }
|
|
[ -f "$destination/backups/run3/b.md" ] || { printf 'b.md should have been backed up before removal.\n' >&2; exit 1; }
|
|
[ -f "$destination/backups/run3/c.md" ] || { printf 'c.md should have been backed up even though it was left in place.\n' >&2; exit 1; }
|
|
|
|
rm -f "$source_dir/a.md"
|
|
before_dry=$(find "$destination" -type f | sort)
|
|
out_dry=$(sync_managed_destination "$source_dir" "$destination" dry "$destination" bash bash true)
|
|
after_dry=$(find "$destination" -type f | sort)
|
|
[ "$before_dry" = "$after_dry" ] || { printf 'A dry run must not change the destination at all.\n' >&2; exit 1; }
|
|
printf '%s\n' "$out_dry" | grep -q '^DRYRUN REMOVE .*a\.md$' || { printf 'Dry run should report the planned removal without performing it:\n%s\n' "$out_dry" >&2; exit 1; }
|
|
|
|
plugin_state=' [marketplaces.ponytail]
|
|
source_type = "git"
|
|
source = "https://github.com/DietrichGebert/ponytail.git"
|
|
[plugins."ponytail@ponytail"]
|
|
enabled = true
|
|
[plugins."disabled@market"]
|
|
enabled = false'
|
|
printf "approvals_reviewer = 'auto_review'\n\n[tui]\n" > "$source_dir/config.toml"
|
|
printf "\357\273\277model = 'old'\nmodel_reasoning_effort = 'high'\napprovals_reviewer = 'user'\n%s\n[other]\nvalue = true\n" "$plugin_state" > "$destination/config.toml"
|
|
original_config=$(cat "$destination/config.toml")
|
|
sync_managed_destination "$source_dir" "$destination" plugins-dry "" "" "" true > /dev/null
|
|
[ "$(cat "$destination/config.toml")" = "$original_config" ] || { printf 'Dry run changed plugin configuration.\n' >&2; exit 1; }
|
|
sync_managed_destination "$source_dir" "$destination" plugins "" "" "" false > /dev/null
|
|
merged_config=$(cat "$destination/config.toml")
|
|
merged_config_normalized=${merged_config//$'\r'/}
|
|
plugin_state_normalized=${plugin_state//$'\r'/}
|
|
[[ "$merged_config_normalized" == *"$plugin_state_normalized"* ]] || { printf 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.\n' >&2; exit 1; }
|
|
[[ "$merged_config" == *"model = 'old'"* && "$merged_config" == *"model_reasoning_effort = 'high'"* && "$merged_config" == *"approvals_reviewer = 'auto_review'"* && "$merged_config" != *"approvals_reviewer = 'user'"* && "$merged_config" == *'[other]'* ]] || { printf 'Updating config.toml must preserve local model settings and enforce automatic review.\n' >&2; exit 1; }
|
|
[ "$(printf '%s\n' "$merged_config" | grep -n "model = 'old'" | cut -d: -f1)" -lt "$(printf '%s\n' "$merged_config" | grep -n '^\[tui\]$' | cut -d: -f1)" ] || { printf 'Model must remain in TOML root table.\n' >&2; exit 1; }
|
|
[ "$(printf '%s\n' "$merged_config" | grep -n "model_reasoning_effort = 'high'" | cut -d: -f1)" -lt "$(printf '%s\n' "$merged_config" | grep -n '^\[tui\]$' | cut -d: -f1)" ] || { printf 'Model effort must remain in TOML root table.\n' >&2; exit 1; }
|
|
[ "$(cat "$destination/backups/plugins/config.toml")" = "$original_config" ] || { printf 'Original plugin configuration must be backed up.\n' >&2; exit 1; }
|
|
sync_managed_destination "$source_dir" "$destination" plugins-repeat "" "" "" false > /dev/null
|
|
[ "$(cat "$destination/config.toml")" = "$merged_config" ] && [ ! -d "$destination/backups/plugins-repeat" ] || { printf 'Preserving plugin configuration must be idempotent.\n' >&2; exit 1; }
|
|
|
|
out_summary=$(sync_managed_destination "$source_dir" "$destination" "summary" "" "" "" false true)
|
|
if printf '%s\n' "$out_summary" | grep -Eq '^(CREATE|UPDATE|UNCHANGED|BACKUP) '; then
|
|
printf 'Summary mode must not print per-file lines: %s\n' "$out_summary" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$(printf '%s\n' "$out_summary" | grep -c '^SYNC .* unchanged')" -ne 1 ]; then
|
|
printf 'Summary mode must print exactly one SYNC tally line: %s\n' "$out_summary" >&2
|
|
exit 1
|
|
fi
|
|
|
|
generated="$work/generated"
|
|
package="$generated/codex-bash"
|
|
test_home="$work/home"
|
|
legacy="$test_home/.codex"
|
|
mkdir -p "$package/skills/example" "$legacy/skills/example"
|
|
printf 'managed skill' > "$package/skills/example/SKILL.md"
|
|
cp "$package/skills/example/SKILL.md" "$legacy/skills/example/SKILL.md"
|
|
printf 'customized' > "$legacy/skills/example/custom.md"
|
|
printf 'foreign' > "$legacy/skills/example/foreign.md"
|
|
declare -A legacy_manifest
|
|
# shellcheck disable=SC2034
|
|
legacy_manifest[skills/example/SKILL.md]=$(sha256_of_file "$legacy/skills/example/SKILL.md")
|
|
# shellcheck disable=SC2034
|
|
legacy_manifest[skills/example/custom.md]=$(printf '0%.0s' {1..64})
|
|
write_managed_manifest "$(manifest_path "$legacy")" legacy_manifest
|
|
mapfile -t targets < <(get_install_targets "$generated" "$test_home" bash codex)
|
|
[[ "${targets[0]}" = *"|$test_home/.agents/skills" ]] || { printf 'Canonical skills must be installed first.\n' >&2; exit 1; }
|
|
mkdir -p "$test_home/.agents/skills/backups/old/example"
|
|
printf 'old backup' > "$test_home/.agents/skills/backups/old/example/SKILL.md"
|
|
sync_managed_destination "${targets[0]%%|*}" "${targets[0]#*|}" migrate-dry "" "" "" true > /dev/null
|
|
[ -f "$test_home/.agents/skills/backups/old/example/SKILL.md" ] || { printf 'Dry run moved an old skill backup.\n' >&2; exit 1; }
|
|
for target_pair in "${targets[@]}"; do
|
|
sync_managed_destination "${target_pair%%|*}" "${target_pair#*|}" migrate "" "" "" false > /dev/null
|
|
done
|
|
[ -f "$test_home/.agents/skills/example/SKILL.md" ] && [ ! -f "$legacy/skills/example/SKILL.md" ] && [ -f "$legacy/backups/migrate/skills/example/SKILL.md" ] || { printf 'Skill migration must remove and back up the managed duplicate.\n' >&2; exit 1; }
|
|
[ ! -e "$test_home/.agents/skills/backups" ] && [ "$(find "$test_home/.agents/.ai-config-skill-backups" -name SKILL.md -type f -exec grep -l '^old backup$' {} + | wc -l)" -eq 1 ] || { printf 'Old skill backup must be preserved outside the skill tree.\n' >&2; exit 1; }
|
|
[ -f "$legacy/skills/example/custom.md" ] && [ -f "$legacy/skills/example/foreign.md" ] || { printf 'Skill migration must preserve modified and foreign files.\n' >&2; exit 1; }
|
|
for target_pair in "${targets[@]}"; do
|
|
sync_managed_destination "${target_pair%%|*}" "${target_pair#*|}" migrate-repeat "" "" "" false > /dev/null
|
|
done
|
|
[ ! -d "$legacy/backups/migrate-repeat" ] || { printf 'Skill migration must be idempotent.\n' >&2; exit 1; }
|
|
printf 'updated managed skill' > "$package/skills/example/SKILL.md"
|
|
for target_pair in "${targets[@]}"; do
|
|
sync_managed_destination "${target_pair%%|*}" "${target_pair#*|}" skill-update "" "" "" false > /dev/null
|
|
done
|
|
[ "$(cat "$test_home/.agents/skills/example/SKILL.md")" = 'updated managed skill' ] || { printf 'Managed skill update did not replace the file.\n' >&2; exit 1; }
|
|
[ "$(cat "$test_home/.agents/.ai-config-skill-backups/skill-update/example/SKILL.md")" = 'managed skill' ] || { printf 'New skill backup must be outside the skill tree.\n' >&2; exit 1; }
|
|
[ -z "$(find "$test_home/.agents/skills/example" -maxdepth 1 -name '.ai-config-write.*' -print -quit)" ] || { printf 'Managed skill update left a temporary file.\n' >&2; exit 1; }
|
|
rm -f -- "$package/skills/example/SKILL.md"
|
|
sync_managed_destination "${targets[0]%%|*}" "${targets[0]#*|}" skill-remove "" "" "" false > /dev/null
|
|
[ ! -f "$test_home/.agents/skills/example/SKILL.md" ] && [ "$(cat "$test_home/.agents/.ai-config-skill-backups/skill-remove/example/SKILL.md")" = 'updated managed skill' ] || { printf 'Deselected skill must be removed and backed up outside the skill tree.\n' >&2; exit 1; }
|
|
printf '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' > "$source_dir/concurrency.txt"
|
|
sync_managed_destination "$source_dir" "$destination" concurrency "" "" "" false false true 7 > /dev/null
|
|
[ "$(cat "$destination/concurrency.txt")" = 7 ] || { printf 'Standalone concurrency placeholder must be resolved.\n' >&2; exit 1; }
|
|
[ -z "$(find "$destination" -name '.ai-config-write.*' -print -quit)" ] || { printf 'Atomic writes left a temporary file.\n' >&2; exit 1; }
|
|
[ ! -e "$destination/.ai-config.lock" ] || { printf 'Sync must release its destination lock.\n' >&2; exit 1; }
|
|
mkdir -p "$source_dir/skills/semantic-search"
|
|
printf 'search skill' > "$source_dir/skills/semantic-search/SKILL.md"
|
|
sync_managed_destination "$source_dir" "$destination" search-off "" "" "" false false true 5 true false > /dev/null
|
|
[ ! -e "$destination/skills/semantic-search/SKILL.md" ] || { printf 'The semantic-search skill must not be installed while retrieval is disabled.\n' >&2; exit 1; }
|
|
sync_managed_destination "$source_dir" "$destination" search-on "" "" "" false false true 5 true true > /dev/null
|
|
[ -f "$destination/skills/semantic-search/SKILL.md" ] || { printf 'The semantic-search skill must be installed while retrieval is enabled.\n' >&2; exit 1; }
|
|
sync_managed_destination "$source_dir" "$destination" search-off-again "" "" "" false false true 5 true false > /dev/null
|
|
[ ! -e "$destination/skills/semantic-search/SKILL.md" ] || { printf 'Disabling retrieval must remove the setup-owned semantic-search skill.\n' >&2; exit 1; }
|
|
rm -r -- "$source_dir/skills/semantic-search"
|
|
printf 'powershell other-host 1\n' > "$destination/.ai-config.lock"
|
|
manifest_before=$(cat "$destination/.ai-config-manifest.tsv")
|
|
if sync_managed_destination "$source_dir" "$destination" locked "" "" "" false > /dev/null 2>&1; then printf 'A held destination lock must block a parallel sync.\n' >&2; exit 1; fi
|
|
[ "$(cat "$destination/.ai-config-manifest.tsv")" = "$manifest_before" ] || { printf 'A blocked sync must not write the manifest.\n' >&2; exit 1; }
|
|
bash -c 'exit 0' & exited_pid=$!
|
|
wait "$exited_pid"
|
|
printf 'bash %s %s\n' "$(uname -n)" "$exited_pid" > "$destination/.ai-config.lock"
|
|
sync_managed_destination "$source_dir" "$destination" stale-lock "" "" "" false > /dev/null
|
|
[ ! -e "$destination/.ai-config.lock" ] || { printf 'A stale lock from an exited process must be replaced and released.\n' >&2; exit 1; }
|
|
[ ! -e "$destination/.ai-config-transaction.json" ] || { printf 'A completed sync must delete its transaction journal.\n' >&2; exit 1; }
|
|
! grep -q 'ai-config-transaction' "$destination/.ai-config-manifest.tsv" || { printf 'The transaction journal must never be listed in the manifest.\n' >&2; exit 1; }
|
|
|
|
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
|
|
tx_source="$work/tx-source"
|
|
tx_destination="$work/tx-destination"
|
|
mkdir -p "$tx_source"
|
|
printf 'v1' > "$tx_source/keep.md"
|
|
printf 'G' > "$tx_source/gone.md"
|
|
sync_managed_destination "$tx_source" "$tx_destination" tx1 "" "" "" false > /dev/null
|
|
mkdir -p "$tx_destination/backups/tx2"
|
|
cp "$tx_destination/keep.md" "$tx_destination/backups/tx2/keep.md"
|
|
mv "$tx_destination/gone.md" "$tx_destination/backups/tx2/gone.md"
|
|
printf 'v2' > "$tx_destination/keep.md"
|
|
printf 'N' > "$tx_destination/new.md"
|
|
printf 'edited by the user' > "$tx_destination/edited.md"
|
|
journal="$tx_destination/.ai-config-transaction.json"
|
|
cat > "$journal" <<EOF
|
|
{"stamp": "tx2", "files": [
|
|
{"path": "keep.md", "action": "update", "backup": "tx2/keep.md", "sha256": "$(sha256_of_string v2)"},
|
|
{"path": "gone.md", "action": "remove", "backup": "tx2/gone.md"},
|
|
{"path": "new.md", "action": "create", "sha256": "$(sha256_of_string N)"},
|
|
{"path": "edited.md", "action": "create", "sha256": "$(sha256_of_string E)"},
|
|
{"path": "unreached.md", "action": "update", "backup": "tx2/unreached.md", "sha256": "$(printf '0%.0s' {1..64})"}
|
|
]}
|
|
EOF
|
|
out_tx_dry=$(sync_managed_destination "$tx_source" "$tx_destination" tx-dry "" "" "" true)
|
|
printf '%s\n' "$out_tx_dry" | grep -qx "DRYRUN ROLLBACK $tx_destination tx2" || { printf 'Dry run must report the pending rollback:\n%s\n' "$out_tx_dry" >&2; exit 1; }
|
|
[ -f "$journal" ] && [ "$(cat "$tx_destination/keep.md")" = v2 ] || { printf 'Dry run must not roll back.\n' >&2; exit 1; }
|
|
out_tx=$(sync_managed_destination "$tx_source" "$tx_destination" tx3 "" "" "" false)
|
|
printf '%s\n' "$out_tx" | grep -qx "ROLLBACK $tx_destination tx2" || { printf 'Expected a ROLLBACK line:\n%s\n' "$out_tx" >&2; exit 1; }
|
|
[ "$(printf '%s\n' "$out_tx" | grep -c '^WARN .*edited\.md')" -eq 1 ] || { printf 'A created file changed since the interruption must be reported:\n%s\n' "$out_tx" >&2; exit 1; }
|
|
[ "$(cat "$tx_destination/keep.md")" = v1 ] || { printf 'Rollback must restore an updated file from its backup.\n' >&2; exit 1; }
|
|
[ "$(cat "$tx_destination/gone.md")" = G ] || { printf 'Rollback must restore a removed file from its backup.\n' >&2; exit 1; }
|
|
[ ! -e "$tx_destination/new.md" ] || { printf 'Rollback must delete a file the interrupted run created.\n' >&2; exit 1; }
|
|
[ "$(cat "$tx_destination/edited.md")" = 'edited by the user' ] || { printf 'Rollback must keep a created file that changed since.\n' >&2; exit 1; }
|
|
[ ! -e "$tx_destination/unreached.md" ] || { printf 'Rollback must skip entries the interrupted run never reached.\n' >&2; exit 1; }
|
|
[ ! -e "$journal" ] || { printf 'Rollback must delete the journal.\n' >&2; exit 1; }
|
|
[ -z "$(printf '%s\n' "$out_tx" | grep -Ev '^(UNCHANGED|SOURCE|ROLLBACK|WARN) ')" ] || { printf 'The sync after a rollback must find the prior state:\n%s\n' "$out_tx" >&2; exit 1; }
|
|
|
|
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback\n'; fi
|