diff --git a/AGENTS.md b/AGENTS.md index ba3a429c..d368db7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,26 +76,35 @@ cd android && ./gradlew :core:protocol:test # Android protocol conformance ## Local binary deployment -Deploy with `scripts/deploy-local.sh [tag]` after `bun run build:single-exe`. -The script signs with a pinned codesigning identity, copies to a new versioned -filename, repoints the `~/.hapi/bin/hapi` symlink, restarts the hub, refreshes -a running runner, and rolls back if `/health` fails. +Deploy with `scripts/deploy-local.sh [tag]` after `bun run build:single-exe`; +remote Macs: `scripts/deploy-remote.sh [tag]`. -Two macOS rules the script enforces: +The binary always installs to the **fixed path** `~/.hapi/bin/hapi` (a real +file, no versioned filenames). Fixed path is deliberate: macOS TCC keys +permission grants (Documents, media library, ...) to the executable path, so +one stable path means the user grants access once and macOS remembers it +across deploys. Versioned filenames re-prompted for every build - do not +reintroduce them. -1. **Never overwrite `~/.hapi/bin/hapi` in place**, even with an atomic - rename. macOS caches the Mach-O code signature by executable path/mtime; - replacing that path can make the embedded signature disagree with the - cached signature and kill every new process with `OS_REASON_CODESIGNING` / - `embedded signature doesn't match attached signature` (often exit 137). A - plain `cp` also loses the signed mtime. Always copy to a new versioned - filename, keep the stable path as a symlink, and keep the previous - versioned file for rollback (do not delete it while sessions are running). -2. **Never ship an ad-hoc signature** (`--sign -`). Ad-hoc signatures have no - stable identity, so macOS TCC treats every build as a new app and re-asks - each protected permission (Documents, Downloads, media library, ...). Pin - one Apple Development identity instead: SHA-1 in `~/.hapi/signing-identity`, - override via `HAPI_SIGN_IDENTITY`, signed identifier `run.hapi.cli`. +The fixed path has one hazard: the kernel caches the Mach-O signature per +path, so overwriting it can kill new processes with +`OS_REASON_CODESIGNING` / `embedded signature doesn't match attached +signature` (often exit 137). The deploy scripts handle it: + +1. back up the installed binary to `~/.hapi/bin/backups/` (newest + `HAPI_KEEP_BACKUPS` kept, default 2; never exec from the backup dir), +2. install the new file with a fresh mtime so the path-keyed signature cache + re-reads it, +3. prove it execs repeatedly (`hapi --version` x3) and `codesign --verify`, +4. restart the hub (local) / kickstart the launchd job (remote) and verify + (`/health` locally; runner state + exec path on the remote), +5. on any failure restore the backup onto the fixed path and restart. + +Never leave an unverified binary installed. Never ship an ad-hoc signature +(`--sign -`): ad-hoc signatures have no stable identity, so macOS TCC treats +every build as a new app and re-asks each protected permission. Pin one Apple +Development identity instead: SHA-1 in `~/.hapi/signing-identity`, override +via `HAPI_SIGN_IDENTITY`, signed identifier `run.hapi.cli`. The runner must be refreshed on every deploy (`hapi runner start` replaces the stale one; running sessions survive). Compiled binaries never self-update: the @@ -104,9 +113,9 @@ fixed for the life of the process. A stale runner keeps old machine RPCs and capability flags, so hub features can fail with "restart the runner" errors. Remote Macs with the same layout: `scripts/deploy-remote.sh [tag]` -(signs locally, copies to a new versioned file, swaps the symlink, restarts the -launchd job; label defaults to `com.hapi.runner`, override with -`HAPI_REMOTE_LAUNCHD_LABEL`). +(signs locally, uploads next to the fixed path, installs by move, verifies, +then kickstarts the launchd job; label defaults to `com.hapi.runner`, override +with `HAPI_REMOTE_LAUNCHD_LABEL`). Agent sessions must not run recursive `$HOME` sweeps (`find ~`, `du -sh ~`) without pruning TCC-protected folders (`~/Music`, `~/Pictures`, `~/Movies`, @@ -114,8 +123,8 @@ without pruning TCC-protected folders (`~/Music`, `~/Pictures`, `~/Movies`, Apple Music prompt attributed to the hapi binary. After deploy: `curl -fsS http://127.0.0.1:3006/health`, `~/.hapi/bin/hapi ---version`, `hapi runner status`. If health fails, restore the previous -symlink before doing anything else. +--version`, `hapi runner status`. If health fails, restore the backup from +`~/.hapi/bin/backups/` before doing anything else. `docs/local-deployment.md` contains the same rationale and rollback checklist. diff --git a/docs/local-deployment.md b/docs/local-deployment.md index bf75d4f4..b62c5c7b 100644 --- a/docs/local-deployment.md +++ b/docs/local-deployment.md @@ -46,39 +46,60 @@ bun run build:single-exe scripts/deploy-local.sh [tag] ``` -The script implements the sequence below, including the runner refresh; read -on to understand the macOS constraints it works around. +Remote Macs with the same layout: -### Code-signature cache (never overwrite the stable path) +```bash +scripts/deploy-remote.sh [tag] # e.g. scripts/deploy-remote.sh k2lab card-dedupe +``` -macOS caches the Mach-O signature against the executable pathname and -modification time. Replacing `~/.hapi/bin/hapi` in place (including a -temp-file + rename) or using a plain `cp` can leave a stale code-signature -cache. The next launch then fails with: +### Fixed install path (why) + +The binary always installs to the **fixed path** `~/.hapi/bin/hapi` (a real +file). macOS TCC keys permission grants (Documents, Downloads, Apple +Music/media library, ...) to the executable path, so one stable path means the +user grants access **once** and macOS remembers it across deploys. The earlier +"new versioned filename per build + symlink" scheme re-prompted for every +build: each deploy looked like a brand-new app, and while the prompt was +unanswered every process touching a protected folder (model probes, session +startup) blocked - which showed up as slow/timeout requests in the app. Do not +reintroduce versioned filenames. + +### Code-signature cache (why deploys verify, and roll back) + +The fixed path has one hazard: the kernel caches the Mach-O signature per +path, so overwriting it can kill new processes with: ```text OS_REASON_CODESIGNING embedded signature doesn't match attached signature ``` -This is a deployment/install issue, not a HAPI application error. Use a fresh -versioned path for every build and keep the stable command path as a symlink. -Do not deploy by copying over the stable path. +This is a deployment/install issue, not a HAPI application error. The deploy +scripts handle it end to end: -### Sign with a stable identity (TCC) +1. back up the installed binary to `~/.hapi/bin/backups/` (newest + `HAPI_KEEP_BACKUPS` kept, default 2; never exec from the backup dir) +2. install the new file with a fresh mtime so the path-keyed signature cache + re-reads it +3. prove it execs repeatedly (`hapi --version` x3) and `codesign --verify` +4. restart the hub (local) / kickstart the launchd job (remote) and verify + (`/health` locally; runner state + exec path on the remote) +5. on any failure restore the backup onto the fixed path and restart -macOS TCC records permission grants against the code-signing identity. An -ad-hoc signature (`codesign --sign -`) has no stable identity, so each rebuild -looks like a brand-new app and every protected permission (Documents, -Downloads, Apple Music/media library, ...) is asked again. Sign every build -with one pinned identity: +Never leave an unverified binary installed. -- `scripts/deploy-local.sh` stores the chosen Apple Development SHA-1 in - `~/.hapi/signing-identity` and reuses it. Override with - `HAPI_SIGN_IDENTITY` when rotating certificates. -- All builds use the signed identifier `run.hapi.cli`. -- With no Apple Development identity the script falls back to ad-hoc; that - still works, but expect TCC prompts to reappear after every deploy. +### Signing + +Sign every build with one pinned identity; never ship an ad-hoc signature +(`codesign --sign -`), which has no stable identity and makes TCC re-ask every +permission: + +- `scripts/sign-build.sh` (called by both deploy scripts) stores the chosen + Apple Development SHA-1 in `~/.hapi/signing-identity` and reuses it; override + with `HAPI_SIGN_IDENTITY` when rotating certificates +- all builds use the signed identifier `run.hapi.cli` +- with no Apple Development identity the scripts fall back to ad-hoc; that + still runs, but expect TCC prompts to reappear after every deploy Agent sessions should also avoid recursive `$HOME` sweeps (`find ~`, `du -sh ~`, ...) unless they prune TCC-protected folders (`~/Music`, @@ -99,74 +120,58 @@ runner's own code stays old until the process restarts: `scripts/deploy-local.sh` runs `hapi runner start` when a runner is already running; the CLI stops the stale runner and starts a fresh one with -`HAPI_CLI_EXECUTABLE` pinned to the stable symlink. Running sessions are -detached and survive the restart. +`HAPI_CLI_EXECUTABLE` pinned to the fixed path. Running sessions are detached +and survive the restart. ### Manual sequence ```bash set -euo pipefail build=cli/dist-exe/bun-darwin-arm64/hapi +bin_dir="$HOME/.hapi/bin" +stable="$bin_dir/hapi" stamp=$(date +%Y%m%d-%H%M%S) -release="$HOME/.hapi/bin/hapi.$stamp" -identity=$(cat "$HOME/.hapi/signing-identity") # SHA-1, or a unique cert name +# Sign with the pinned identity (see scripts/sign-build.sh). +bash scripts/sign-build.sh "$build" -# Bun's linker signature is not suitable after installation; re-sign once. -codesign --remove-signature "$build" 2>/dev/null || true -codesign --force --sign "$identity" --identifier run.hapi.cli "$build" -codesign --verify --deep --strict "$build" +# Back up the installed binary for rollback. +mkdir -p "$bin_dir/backups" +backup="$bin_dir/backups/hapi.$stamp" +[ -f "$stable" ] && cp -p "$stable" "$backup" -# -p preserves the mtime covered by the code-signature cache. -cp -p "$build" "$release" -codesign --verify --deep --strict "$release" -"$release" --version - -# Keep the old target as a rollback point. If hapi is already a symlink, -# replace only the link; otherwise move the legacy regular file aside first. -stable="$HOME/.hapi/bin/hapi" -if [ -L "$stable" ]; then - old_target=$(readlink "$stable") -else - old_target="hapi.bak.$stamp" - mv "$stable" "$HOME/.hapi/bin/$old_target" -fi -ln -sfn "$(basename "$release")" "$stable" +# Install to the fixed path with a fresh mtime, then prove it execs. +rm -f "$stable" +cp "$build" "$stable" +chmod 755 "$stable" +"$stable" --version && "$stable" --version && "$stable" --version +codesign --verify --deep --strict "$stable" launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" sleep 2 curl -fsS http://127.0.0.1:3006/health >/dev/null "$stable" --version -# Refresh a running runner; new sessions already use the new binary via the -# symlink, but the runner's own machine RPCs/capabilities stay stale. +# Refresh a running runner so its machine RPCs/capabilities match. HAPI_CLI_EXECUTABLE="$stable" "$stable" runner start ``` -If the health check fails, immediately restore the prior link and restart the -agent: +If the health check or a cold start fails, restore the backup and restart: ```bash -ln -sfn "$old_target" "$HOME/.hapi/bin/hapi" +rm -f "$stable" +cp "$backup" "$stable" +chmod 755 "$stable" +"$stable" --version launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" ``` -Never use `cp`, `mv`, or `codesign` on the stable symlink target after the -launch agent has been started. Keep versioned binaries until the replacement -has been running and verified. - ### Remote machines -Macs that run the same layout (versioned binary + `~/.hapi/bin/hapi` symlink + -a supervised runner) are updated from here over SSH: - -```bash -scripts/deploy-remote.sh [tag] # e.g. scripts/deploy-remote.sh k2lab stable-signing -``` - -The script signs the build with the same pinned identity, copies it to a new -versioned file, swaps the symlink, restarts the launchd job (`com.hapi.runner` -by default; override with `HAPI_REMOTE_LAUNCHD_LABEL`), and verifies the runner -executes the new file. Running sessions survive; roll back by restoring the -previous symlink and kicking the job again. The remote host needs no build -toolchain — it only receives the signed binary. +`scripts/deploy-remote.sh [tag]` performs the same flow over +SSH: signs locally, checks the remote arch matches, uploads next to the fixed +path, installs by move (fresh inode + mtime), proves the binary execs, then +kickstarts the launchd job (`com.hapi.runner` by default; override with +`HAPI_REMOTE_LAUNCHD_LABEL`) and verifies the runner executes the fixed path. +On any failure it restores the backup and kicks the job again. Running +sessions survive; the remote host needs no build toolchain. diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index 50c81072..2b159d6e 100755 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -1,6 +1,15 @@ #!/bin/bash -# Deploy the freshly built all-in-one binary following docs/local-deployment.md. +# Deploy the freshly built all-in-one binary to the fixed path ~/.hapi/bin/hapi. # Usage: scripts/deploy-local.sh [tag] +# +# Fixed path is deliberate: macOS TCC keys permission grants to the executable +# path, so a stable path means Documents / media-library access is granted once +# and remembered across deploys (versioned filenames re-prompted every build). +# +# At the same time macOS caches the Mach-O signature per path, so overwriting +# the path can kill new processes (exit 137). This script therefore installs +# with a fresh mtime, proves the new binary execs repeatedly, and rolls back +# from ~/.hapi/bin/backups on any failure. set -euo pipefail cd "$(dirname "$0")/.." @@ -8,55 +17,64 @@ cd "$(dirname "$0")/.." build=cli/dist-exe/bun-darwin-arm64/hapi bin_dir="$HOME/.hapi/bin" stable="$bin_dir/hapi" +backup_dir="$bin_dir/backups" stamp=$(date +%Y%m%d-%H%M%S) tag=${1:-} -release="$bin_dir/hapi.$stamp${tag:+-$tag}" if [ ! -x "$build" ]; then echo "error: build missing at $build; run 'bun run build:single-exe' first" >&2 exit 1 fi -mkdir -p "$bin_dir" +mkdir -p "$bin_dir" "$backup_dir" bash scripts/sign-build.sh "$build" -# -p preserves the mtime covered by the code-signature cache. -cp -p "$build" "$release" -codesign --verify --deep --strict "$release" -"$release" --version - -if [ -L "$stable" ]; then - old_target=$(readlink "$stable") -elif [ -e "$stable" ]; then - old_target="hapi.bak.$stamp" - mv "$stable" "$bin_dir/$old_target" -else - old_target="" +# Rollback copy of the currently installed binary. Never exec from here; +# rollback restores it onto the fixed path instead. +backup="" +if [ -f "$stable" ]; then + backup="$backup_dir/hapi.$stamp${tag:+-$tag}" + cp -p "$stable" "$backup" + echo "backup: $backup" fi -ln -sfn "$(basename "$release")" "$stable" -echo "stable link: $stable -> $(readlink "$stable")" -echo "rollback target: ${old_target:-none}" + +restore_backup() { + if [ -z "$backup" ]; then + echo "no backup to restore" >&2 + return 1 + fi + echo "restoring $backup" >&2 + rm -f "$stable" + cp "$backup" "$stable" + chmod 755 "$stable" + "$stable" --version +} + +# Install to the fixed path with a fresh mtime (invalidates the path-keyed +# signature cache), then prove it execs repeatedly. +rm -f "$stable" +cp "$build" "$stable" +chmod 755 "$stable" +"$stable" --version +"$stable" --version +"$stable" --version +codesign --verify --deep --strict "$stable" +echo "installed: $stable" launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" sleep 2 if ! curl -fsS http://127.0.0.1:3006/health >/dev/null; then - echo "health check failed; restoring ${old_target:-none}" >&2 - if [ -n "$old_target" ]; then - ln -sfn "$old_target" "$stable" - else - rm -f "$stable" - fi + echo "health check failed" >&2 + restore_backup || true launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" exit 1 fi echo "health ok" -"$stable" --version # Refresh the runner so its machine RPCs/capabilities match the new binary. -# Compiled binaries never self-update after a deploy: the heartbeat mtime check -# compares against the runner's own resolved exec path, which never changes. -# `runner start` stops the stale runner first; running sessions are unaffected. +# Compiled binaries never self-update: the heartbeat mtime check compares the +# runner's own resolved exec path, which is fixed for the life of the process. runner_state="$HOME/.hapi/runner.state.json" runner_pid=$(sed -n 's/.*"pid": *\([0-9][0-9]*\).*/\1/p' "$runner_state" 2>/dev/null | head -n 1 || true) if [ -n "${runner_pid:-}" ] && kill -0 "$runner_pid" 2>/dev/null; then @@ -70,5 +88,7 @@ else echo "runner not running; skipped refresh" fi -# Keep disk usage bounded: current + previous version (override HAPI_KEEP_VERSIONS). -bash scripts/prune-versions.sh "${HAPI_KEEP_VERSIONS:-2}" "$bin_dir" +# Keep disk usage bounded: newest N backups (default 2), drop legacy versioned files. +bash scripts/prune-backups.sh "${HAPI_KEEP_BACKUPS:-2}" "$bin_dir" + +echo "rollback: rm -f '$stable' && cp '${backup:-}' '$stable' && chmod 755 '$stable' && launchctl kickstart -k gui/$(id -u)/com.hapi.hub" diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 9c17d801..3f193dc2 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1,12 +1,16 @@ #!/bin/bash # Deploy the built-and-signed all-in-one binary to a remote Mac over SSH. # Usage: scripts/deploy-remote.sh [tag] -# e.g. scripts/deploy-remote.sh k2lab stable-signing +# e.g. scripts/deploy-remote.sh k2lab card-dedupe # -# The remote host must use this repo's deployment layout: a versioned binary -# under ~/.hapi/bin/ with the stable `hapi` symlink, supervised by a launchd -# job (default com.hapi.runner; override with HAPI_REMOTE_LAUNCHD_LABEL). -# Running sessions survive the restart; new sessions use the new binary. +# The remote host uses the same fixed-path layout as this repo: +# ~/.hapi/bin/hapi (real file) supervised by a launchd job +# (default com.hapi.runner; override with HAPI_REMOTE_LAUNCHD_LABEL). +# +# Fixed path keeps macOS TCC grants stable (granted once, remembered across +# deploys). To stay safe against the per-path code-signature cache the script +# backs up the installed binary, installs with a fresh mtime, proves the new +# binary execs repeatedly, and rolls back on any failure. set -euo pipefail cd "$(dirname "$0")/.." @@ -21,7 +25,6 @@ fi label=${HAPI_REMOTE_LAUNCHD_LABEL:-com.hapi.runner} build=cli/dist-exe/bun-darwin-arm64/hapi stamp=$(date +%Y%m%d-%H%M%S) -release="hapi.$stamp${tag:+-$tag}" ssh_opts=(-o BatchMode=yes -o ConnectTimeout=10) if [ ! -x "$build" ]; then @@ -29,9 +32,7 @@ if [ ! -x "$build" ]; then exit 1 fi -# Sign locally with the pinned identity: the remote TCC database keys grants -# to the signing identity, so shipping an ad-hoc build there re-triggers -# permission prompts after every deploy. +# Sign locally with the pinned identity so the remote keeps a stable signer. bash scripts/sign-build.sh "$build" local_arch=$(uname -m) @@ -43,40 +44,59 @@ fi remote_home=$(ssh "${ssh_opts[@]}" "$target" 'printf %s "$HOME"') echo "target: $target ($remote_arch)" -echo "release: $release" +echo "release: $stamp${tag:+-$tag}" -# Copy to a NEW versioned filename: macOS caches Mach-O signatures by path, -# so never overwrite an existing executable path. -ssh "${ssh_opts[@]}" "$target" "mkdir -p '$remote_home/.hapi/bin'" -scp -q -C "${ssh_opts[@]}" "$build" "$target:$remote_home/.hapi/bin/$release" +# Upload next to the fixed path, then install by move (fresh inode + mtime). +ssh "${ssh_opts[@]}" "$target" "mkdir -p '$remote_home/.hapi/bin/backups'" +scp -q -C "${ssh_opts[@]}" "$build" "$target:$remote_home/.hapi/bin/.hapi.incoming" ssh "${ssh_opts[@]}" "$target" "STAMP='$stamp' TAG='$tag' LABEL='$label' bash -s" <<'REMOTE' set -euo pipefail bin_dir="$HOME/.hapi/bin" stable="$bin_dir/hapi" -new_name="hapi.$STAMP${TAG:+-$TAG}" -new="$bin_dir/$new_name" +backup_dir="$bin_dir/backups" +incoming="$bin_dir/.hapi.incoming" -if [ ! -x "$new" ]; then - echo "error: uploaded binary missing at $new" >&2 +if [ ! -x "$incoming" ]; then + echo "error: uploaded binary missing at $incoming" >&2 exit 1 fi -codesign --verify --deep --strict "$new" -"$new" --version +backup="" +if [ -f "$stable" ]; then + backup="$backup_dir/hapi.$STAMP${TAG:+-$TAG}" + cp -p "$stable" "$backup" + echo "backup: $backup" +fi + +restore_backup() { + if [ -z "$backup" ]; then + echo "no backup to restore" >&2 + return 1 + fi + echo "restoring $backup" >&2 + rm -f "$stable" + cp "$backup" "$stable" + chmod 755 "$stable" + "$stable" --version +} + +rm -f "$stable" +mv "$incoming" "$stable" +chmod 755 "$stable" +"$stable" --version +"$stable" --version +"$stable" --version +codesign --verify --deep --strict "$stable" +echo "installed: $stable" if ! launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then echo "error: launchd job $LABEL is not loaded on this host" >&2 + restore_backup || true exit 1 fi -old_target="" -if [ -L "$stable" ]; then - old_target=$(readlink "$stable") -fi - -ln -sfn "$new_name" "$stable" launchctl kickstart -k "gui/$(id -u)/$LABEL" runner_pid="" @@ -93,21 +113,23 @@ for _ in $(seq 1 15); do runner_pid="" done -echo "link: $(readlink "$stable")" -echo "rollback target: ${old_target:-none}" if [ -z "$runner_pid" ]; then - echo "warning: runner not up yet; check 'launchctl print gui/$(id -u)/$LABEL'" >&2 + echo "warning: runner did not come up" >&2 + if restore_backup; then + launchctl kickstart -k "gui/$(id -u)/$LABEL" + fi exit 1 fi + echo "runner pid: $runner_pid" echo "runner exe: $runner_exe" case "$runner_exe" in - *"$new_name"*) echo "remote deploy ok" ;; - *) echo "warning: runner executable is not $new_name; it will switch on the next restart" >&2 ;; + "$stable") echo "remote deploy ok" ;; + *) echo "warning: runner executable is $runner_exe, expected $stable" >&2 ;; esac REMOTE -# Keep disk usage bounded on the remote host: current + previous version. -ssh "${ssh_opts[@]}" "$target" "HAPI_KEEP_VERSIONS='${HAPI_KEEP_VERSIONS:-2}' bash -s" < scripts/prune-versions.sh +# Keep remote disk usage bounded: newest N backups, drop legacy versioned files. +ssh "${ssh_opts[@]}" "$target" "HAPI_KEEP_BACKUPS='${HAPI_KEEP_BACKUPS:-2}' bash -s" < scripts/prune-backups.sh -echo "rollback: ssh $target \"ln -sfn ~/.hapi/bin/hapi && launchctl kickstart -k gui/\\\$(id -u)/$label\"" +echo "rollback: ssh $target \"rm -f ~/.hapi/bin/hapi && cp ~/.hapi/bin/hapi && chmod 755 ~/.hapi/bin/hapi && launchctl kickstart -k gui/\\\$(id -u)/$label\"" diff --git a/scripts/prune-backups.sh b/scripts/prune-backups.sh new file mode 100755 index 00000000..90f11f51 --- /dev/null +++ b/scripts/prune-backups.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Keep disk usage bounded for the fixed-path deployment: +# - keep the newest N backup copies in /backups (default 2) +# - remove legacy versioned binaries (hapi.YYYYMMDD-HHMMSS*) from the +# pre-fixed-path era; the fixed path ~/.hapi/bin/hapi is never touched +# Usage: scripts/prune-backups.sh [keep] [bin-dir] +set -euo pipefail + +keep=${1:-${HAPI_KEEP_BACKUPS:-2}} +bin_dir=${2:-$HOME/.hapi/bin} +backup_dir="$bin_dir/backups" + +case "$keep" in + ''|*[!0-9]*) + echo "error: keep must be a number >= 1" >&2 + exit 1 + ;; +esac +if [ "$keep" -lt 1 ]; then + echo "error: keep must be >= 1" >&2 + exit 1 +fi + +if [ ! -d "$bin_dir" ]; then + echo "prune: no $bin_dir, nothing to do" + exit 0 +fi + +freed=0 + +if [ -d "$backup_dir" ]; then + count=0 + for backup in $(ls -1 "$backup_dir" 2>/dev/null | grep -E '^hapi\.' | sort -r || true); do + count=$((count + 1)) + if [ "$count" -le "$keep" ]; then + continue + fi + size=$(stat -f "%z" "$backup_dir/$backup" 2>/dev/null || echo 0) + rm -f "$backup_dir/$backup" + freed=$((freed + size)) + echo "pruned backup: $backup ($((size / 1048576))MB)" + done + echo "kept backups: $((count < keep ? count : keep))" +fi + +for version in $(ls -1 "$bin_dir" 2>/dev/null | grep -E '^hapi\.[0-9]{8}-[0-9]{6}' | sort -r || true); do + size=$(stat -f "%z" "$bin_dir/$version" 2>/dev/null || echo 0) + rm -f "$bin_dir/$version" + freed=$((freed + size)) + echo "removed legacy: $version ($((size / 1048576))MB)" +done + +echo "freed: $((freed / 1048576))MB" diff --git a/scripts/prune-versions.sh b/scripts/prune-versions.sh deleted file mode 100755 index ca1087b2..00000000 --- a/scripts/prune-versions.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# Prune old versioned hapi binaries in a deployment bin dir, keeping the current -# symlink target plus the N most recent previous versions (default 2 total). -# Usage: scripts/prune-versions.sh [keep-count] [bin-dir] -# keep-count defaults to $HAPI_KEEP_VERSIONS, then 2 (current + previous). -# bin-dir defaults to $HOME/.hapi/bin. -# Safe to run while sessions are live: unlinking a binary does not affect -# running processes (they keep the open file), only future rollbacks. -set -euo pipefail - -keep=${1:-${HAPI_KEEP_VERSIONS:-2}} -bin_dir=${2:-$HOME/.hapi/bin} - -case "$keep" in - ''|*[!0-9]*) - echo "error: keep-count must be a number >= 2" >&2 - exit 1 - ;; -esac -if [ "$keep" -lt 2 ]; then - echo "error: keep-count must be >= 2 (current + previous)" >&2 - exit 1 -fi - -if [ ! -d "$bin_dir" ]; then - echo "prune: no $bin_dir, nothing to do" - exit 0 -fi - -stable="$bin_dir/hapi" -current="" -if [ -L "$stable" ]; then - current=$(readlink "$stable") -fi - -# Newest-first by filename: hapi.YYYYMMDD-HHMMSS[-tag] sorts chronologically. -versions=$(ls -1 "$bin_dir" 2>/dev/null | grep -E '^hapi\.[0-9]{8}-[0-9]{6}' | sort -r || true) - -kept="" -count=0 -freed=0 -if [ -n "$current" ] && [ -f "$bin_dir/$current" ]; then - kept="$current" - count=1 -fi - -for version in $versions; do - [ "$version" = "$current" ] && continue - if [ "$count" -lt "$keep" ]; then - kept="$kept $version" - count=$((count + 1)) - continue - fi - size=$(stat -f "%z" "$bin_dir/$version" 2>/dev/null || echo 0) - rm -f "$bin_dir/$version" - freed=$((freed + size)) - echo "pruned: $version ($((size / 1048576))MB)" -done - -echo "kept:${kept:- none} (count=$count)" -echo "freed: $((freed / 1048576))MB"