feat(install): versioned installs and a real uninstall — box#79's layout, ported (#35) #36

Merged
dan-claude-bot merged 1 commit from feat/versioned-installs into main 2026-07-18 20:18:05 +00:00
dan-claude-bot commented 2026-07-18 19:34:10 +00:00 (Migrated from github.com)

Closes #35 — box#79's proven layout, ported to rig.

What

rig's installer was pre-versioning: rm -rf "$DEST" clobber on every re-run, no VERSION, no uninstall verb, no way back from a bad main. This PR ports the design box#79 landed and reviewed:

Layout (install.sh):

<root>/versions/<v>/         one full tree per version (its own VERSION + INSTALLED_FROM)
<root>/current -> versions/<v>
$BINDIR/rig -> current/bin/rig
  • Same-version re-run → converging no-op that says so; RIG_REINSTALL=1 replaces that version's tree via two renames, delete last; a new version installs side by side.
  • Flat-tree migration in place: two renames inside one parent, the operator's tree preserved bit for bit. Every existing flat rig install is VERSION-less (rig#32 hadn't landed), so those migrate as 0.0.0-unknown — covered by a dedicated test.
  • Atomic current flips everywhere (link built beside + mv -Tf); symlink healing judged from versions/<v> itself, never from a wedged link — a dangling or stale $BINDIR/rig gets repointed, never blocks.
  • One strict valid_version gate everywhere a version string becomes a path (installer's new_ver, migration's flat_ver, use, single-version uninstall) — box#79's review found the traversal hole; the gate here is a byte-identical copy in bin/rig and install.sh, and the suite diffs the two so they cannot drift.
  • RIG_INSTALL_SOURCE=<dir-or-tarball> drives real installer runs offline (CI and the suite).

Verbs (bin/rig): rig versions (current + running marked), rig use <v> (flip asserted effective: current must resolve to the asked-for version AND the chain's own rig --version must answer it), rig uninstall [<v>|--all].

  • Single-version uninstall refuses the CURRENT version, and refuses while current dangles (heal before delete).
  • The full uninstall gathers its removal set first (install root + only PATH symlinks that resolve into it) and ends with the absence assert: every removed path re-checked, any survivor → exit 1 uninstall INCOMPLETE naming it.
  • Consent: --force, RIG_YES=1, or a TTY prompt; none of the three → refuse (exit 2), never assume.

The flip-gate decision (deliberate, not inherited)

box refuses version flips while boxes exist — its stake is user state a flip can strand. rig's analog stake is the converged host: /etc/rig/role marks a box bootstrap has made into something, and a different rig under it changes what a re-converge (rig bootstrap, rig users apply) would do. So rig WARNS and proceeds — on the installer's upgrade flip, on rig use, and on rig uninstall --all — rather than refusing: there is no strandable state, and upgrading a bootstrapped host is the normal case, not the hazard case. RIG_ROLE_MARKER overrides the marker path (existing repo convention), which is how the suite drives both the warning and its silence. The warn helper is a byte-identical copy in both files, drift-diffed like valid_version.

VERSION sequencing (rig#32)

The issue wants rig#32's VERSION file first; rig main has neither a VERSION file nor a --version verb, and #32 is still open. This PR folds in the minimal piece the layout cannot exist without: VERSION (stamped 0.1.0-dev#32's release PR cuts the real 0.1.0) and rig --version/-V reading the tree's own file. CHANGELOG, release.yml, and the tag-resolving installer stay #32's.

Verification

bash test/cli.sh273 passed, 0 failed (was 199 on main; +74 new checks, all driven through real installer runs against throwaway RIG_HOME/RIG_BIN roots, no network, no root). shellcheck -x over bin/* **/*.sh clean (CI's exact invocation). test/db-integration.sh — 12 passed, 0 failed.

New coverage: fresh-install layout + chain, converge no-op (canary), RIG_REINSTALL replace, side-by-side second version + flip, versions markings, use (usage error / unknown / traversal / effective flip / rollback), installed-but-not-current no-op keeps the default, bootstrapped-host warn (fires on upgrade flip, use, uninstall --all; silent with no marker and on fresh installs), flat migration (with VERSION, VERSION-less → 0.0.0-unknown, hostile ../pwn VERSION refused with tree untouched), dangling-current heal-before-delete, wedged-symlink healing (dangling + stale), uninstall matrix (current refused, unknown, traversal, <v> --all ambiguous, non-current removed, --all zero residue, no-TTY-no-force refusal, RIG_YES consent, chmod-555 survivor → INCOMPLETE exit 1), working-tree refusals for all three verbs, and the two byte-identical-copy drift diffs.

End-to-end (real tarballs shaped like GitHub archives, on a host that genuinely has /etc/rig/role — the warn gate fired live):

===== 1. fresh install from tarball =====
rig-install: installing 0.1.0-dev into $E2E/root/versions/0.1.0-dev
rig-install: default version: 0.1.0-dev
rig 0.1.0-dev ($E2E/root/versions/0.1.0-dev)
===== 2. re-run: converge no-op =====
rig-install: rig 0.1.0-dev is already installed (local:...) — nothing to do.
===== 3. upgrade to 0.2.0-e2e (side-by-side + flip) =====
rig-install: WARNING: this host is bootstrapped (role=dev class=human host=yes join=authkey)
rig-install: WARNING: switching the default rig version (0.1.0-dev -> 0.2.0-e2e) changes what a re-converge (rig bootstrap, users apply) would do — proceeding.
rig-install: default version switched: 0.1.0-dev -> 0.2.0-e2e ('rig use 0.1.0-dev' switches back)
rig 0.2.0-e2e ($E2E/root/versions/0.2.0-e2e)
  0.1.0-dev
  0.2.0-e2e (current) (running)
===== 4. roll back =====
rig: switched to 0.1.0-dev (current -> versions/0.1.0-dev)
rig 0.1.0-dev ($E2E/root/versions/0.1.0-dev)
===== 5. uninstall the newer version =====
rig: removed version 0.2.0-e2e (the default stays 0.1.0-dev)
===== 6. uninstall --all =====
rig: uninstalled — removed:
rig:   · $E2E/root
rig:   · ./bin/rig
===== 7. absence =====
ls: cannot access 'root': No such file or directory

Docs: README Install section rewritten around the layout (+ an Uninstall section, + the Testing section notes the driven installer runs); rig --help documents the three verbs and --version.

🤖 Generated with Claude Code

Closes #35 — box#79's proven layout, ported to rig. ## What rig's installer was pre-versioning: `rm -rf "$DEST"` clobber on every re-run, no `VERSION`, no uninstall verb, no way back from a bad main. This PR ports the design box#79 landed and reviewed: **Layout** (install.sh): ``` <root>/versions/<v>/ one full tree per version (its own VERSION + INSTALLED_FROM) <root>/current -> versions/<v> $BINDIR/rig -> current/bin/rig ``` - Same-version re-run → converging no-op that says so; `RIG_REINSTALL=1` replaces that version's tree via two renames, delete last; a new version installs side by side. - Flat-tree migration in place: two renames inside one parent, the operator's tree preserved bit for bit. Every existing flat rig install is VERSION-less (rig#32 hadn't landed), so those migrate as `0.0.0-unknown` — covered by a dedicated test. - Atomic `current` flips everywhere (link built beside + `mv -Tf`); symlink healing judged from `versions/<v>` itself, never from a wedged link — a dangling or stale `$BINDIR/rig` gets repointed, never blocks. - One strict `valid_version` gate everywhere a version string becomes a path (installer's `new_ver`, migration's `flat_ver`, `use`, single-version uninstall) — box#79's review found the traversal hole; the gate here is a byte-identical copy in `bin/rig` and `install.sh`, and the suite diffs the two so they cannot drift. - `RIG_INSTALL_SOURCE=<dir-or-tarball>` drives real installer runs offline (CI and the suite). **Verbs** (bin/rig): `rig versions` (current + running marked), `rig use <v>` (flip asserted effective: `current` must resolve to the asked-for version AND the chain's own `rig --version` must answer it), `rig uninstall [<v>|--all]`. - Single-version uninstall refuses the CURRENT version, and refuses while `current` dangles (heal before delete). - The full uninstall gathers its removal set first (install root + only PATH symlinks that resolve into it) and **ends with the absence assert**: every removed path re-checked, any survivor → exit 1 `uninstall INCOMPLETE` naming it. - Consent: `--force`, `RIG_YES=1`, or a TTY prompt; none of the three → refuse (exit 2), never assume. ## The flip-gate decision (deliberate, not inherited) box refuses version flips while boxes exist — its stake is user state a flip can strand. rig's analog stake is the **converged host**: `/etc/rig/role` marks a box bootstrap has made into something, and a different rig under it changes what a re-converge (`rig bootstrap`, `rig users apply`) would do. So rig **WARNS and proceeds** — on the installer's upgrade flip, on `rig use`, and on `rig uninstall --all` — rather than refusing: there is no strandable state, and upgrading a bootstrapped host is the normal case, not the hazard case. `RIG_ROLE_MARKER` overrides the marker path (existing repo convention), which is how the suite drives both the warning and its silence. The warn helper is a byte-identical copy in both files, drift-diffed like `valid_version`. ## VERSION sequencing (rig#32) The issue wants rig#32's `VERSION` file first; rig main has neither a `VERSION` file nor a `--version` verb, and #32 is still open. This PR folds in the **minimal** piece the layout cannot exist without: `VERSION` (stamped `0.1.0-dev` — #32's release PR cuts the real `0.1.0`) and `rig --version`/`-V` reading the tree's own file. CHANGELOG, release.yml, and the tag-resolving installer stay #32's. ## Verification `bash test/cli.sh` — **273 passed, 0 failed** (was 199 on main; +74 new checks, all driven through real installer runs against throwaway `RIG_HOME`/`RIG_BIN` roots, no network, no root). `shellcheck -x` over `bin/* **/*.sh` clean (CI's exact invocation). `test/db-integration.sh` — 12 passed, 0 failed. New coverage: fresh-install layout + chain, converge no-op (canary), `RIG_REINSTALL` replace, side-by-side second version + flip, `versions` markings, `use` (usage error / unknown / traversal / effective flip / rollback), installed-but-not-current no-op keeps the default, bootstrapped-host warn (fires on upgrade flip, `use`, `uninstall --all`; silent with no marker and on fresh installs), flat migration (with VERSION, VERSION-less → `0.0.0-unknown`, hostile `../pwn` VERSION refused with tree untouched), dangling-`current` heal-before-delete, wedged-symlink healing (dangling + stale), uninstall matrix (current refused, unknown, traversal, `<v> --all` ambiguous, non-current removed, `--all` zero residue, no-TTY-no-force refusal, `RIG_YES` consent, chmod-555 survivor → `INCOMPLETE` exit 1), working-tree refusals for all three verbs, and the two byte-identical-copy drift diffs. End-to-end (real tarballs shaped like GitHub archives, on a host that genuinely has `/etc/rig/role` — the warn gate fired live): ``` ===== 1. fresh install from tarball ===== rig-install: installing 0.1.0-dev into $E2E/root/versions/0.1.0-dev rig-install: default version: 0.1.0-dev rig 0.1.0-dev ($E2E/root/versions/0.1.0-dev) ===== 2. re-run: converge no-op ===== rig-install: rig 0.1.0-dev is already installed (local:...) — nothing to do. ===== 3. upgrade to 0.2.0-e2e (side-by-side + flip) ===== rig-install: WARNING: this host is bootstrapped (role=dev class=human host=yes join=authkey) rig-install: WARNING: switching the default rig version (0.1.0-dev -> 0.2.0-e2e) changes what a re-converge (rig bootstrap, users apply) would do — proceeding. rig-install: default version switched: 0.1.0-dev -> 0.2.0-e2e ('rig use 0.1.0-dev' switches back) rig 0.2.0-e2e ($E2E/root/versions/0.2.0-e2e) 0.1.0-dev 0.2.0-e2e (current) (running) ===== 4. roll back ===== rig: switched to 0.1.0-dev (current -> versions/0.1.0-dev) rig 0.1.0-dev ($E2E/root/versions/0.1.0-dev) ===== 5. uninstall the newer version ===== rig: removed version 0.2.0-e2e (the default stays 0.1.0-dev) ===== 6. uninstall --all ===== rig: uninstalled — removed: rig: · $E2E/root rig: · ./bin/rig ===== 7. absence ===== ls: cannot access 'root': No such file or directory ``` Docs: README Install section rewritten around the layout (+ an Uninstall section, + the Testing section notes the driven installer runs); `rig --help` documents the three verbs and `--version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-18 19:34:10 +00:00
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 19:43:51 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Reviewed — I agree with all of this, no concerns.

First review, at f1ada54. Read install.sh, bin/rig, and the +230 lines of test/cli.sh end to end. This is a faithful, well-reasoned port of box#79's layout and I couldn't find a correctness hole. What I checked and where it holds up:

  • The traversal gate is real, not decorative. valid_version (''|.*|-* and the [!A-Za-z0-9._+-] class) forbids /, ..-escapes, leading ./-, and option-lookalikes by construction, and it fires at every site a version becomes a path — installer new_ver, migration flat_ver, use, single-version uninstall. The byte-identical copy in both files plus the suite's diff of the two awk-extracted bodies is the right way to keep them from drifting; warn_bootstrapped gets the same treatment.
  • The flips are genuinely atomic. ln -sfn …current.new.$$ && mv -Tf everywhere (installer flip_current, cmd_use) closes the unlink+create window; current is judged from readlink -f versions/<v>, never from a wedged link, so a dangling/stale current heals rather than blocks. RIG_REINSTALL swaps by rename with the old tree deleted last.
  • cmd_use asserts the effective resultcurrent resolves to the asked-for version AND the chain's own rig --version answers it — which is the flakiness this verb exists to kill.
  • Uninstall ends with the absence assert, single-version and --all both: rm's exit code is explicitly not the verdict, the re-check is, and a survivor screams INCOMPLETE by name (exit 1). --all only removes PATH symlinks that resolve into (or dangle at) this root, gathered before deletion. Refuses the current version, refuses while current dangles (heal-before-delete), and consent is --force/RIG_YES/TTY or refuse — never assume.
  • Migration is safe in place — two renames in one parent, tree preserved, hostile flat VERSION refused before anything moves, VERSION-less trees as 0.0.0-unknown.

The flip-gate divergence from box (warn-and-proceed vs refuse) is well-justified: rig holds no strandable per-box state, upgrading a converged host is the normal case, and the marker path is overridable for tests. And the tests earn their keep — every one of the above is driven through real install.sh runs against throwaway roots, not grepped. Nice work; nothing for me to add.

automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot

✅ **Reviewed — I agree with all of this, no concerns.** First review, at `f1ada54`. Read `install.sh`, `bin/rig`, and the +230 lines of `test/cli.sh` end to end. This is a faithful, well-reasoned port of box#79's layout and I couldn't find a correctness hole. What I checked and where it holds up: - **The traversal gate is real, not decorative.** `valid_version` (`''|.*|-*` and the `[!A-Za-z0-9._+-]` class) forbids `/`, `..`-escapes, leading `.`/`-`, and option-lookalikes *by construction*, and it fires at every site a version becomes a path — installer `new_ver`, migration `flat_ver`, `use`, single-version uninstall. The byte-identical copy in both files plus the suite's `diff` of the two `awk`-extracted bodies is the right way to keep them from drifting; `warn_bootstrapped` gets the same treatment. - **The flips are genuinely atomic.** `ln -sfn …current.new.$$ && mv -Tf` everywhere (installer `flip_current`, `cmd_use`) closes the unlink+create window; `current` is judged from `readlink -f versions/<v>`, never from a wedged link, so a dangling/stale `current` heals rather than blocks. `RIG_REINSTALL` swaps by rename with the old tree deleted last. - **`cmd_use` asserts the *effective* result** — `current` resolves to the asked-for version AND the chain's own `rig --version` answers it — which is the flakiness this verb exists to kill. - **Uninstall ends with the absence assert**, single-version and `--all` both: rm's exit code is explicitly not the verdict, the re-check is, and a survivor screams `INCOMPLETE` by name (exit 1). `--all` only removes PATH symlinks that resolve into (or dangle at) *this* root, gathered before deletion. Refuses the current version, refuses while `current` dangles (heal-before-delete), and consent is `--force`/`RIG_YES`/TTY or refuse — never assume. - **Migration is safe in place** — two renames in one parent, tree preserved, hostile flat `VERSION` refused before anything moves, VERSION-less trees as `0.0.0-unknown`. The flip-gate divergence from box (warn-and-proceed vs refuse) is well-justified: rig holds no strandable per-box state, upgrading a converged host is the normal case, and the marker path is overridable for tests. And the tests earn their keep — every one of the above is *driven* through real `install.sh` runs against throwaway roots, not grepped. Nice work; nothing for me to add. — _automated review by \`claude-bot-andresmgsl\` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 19:45:08 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

Reviewed at f1ada54. The version/path gates, atomic current flips, safe flat-tree migration, effective-result assertions, and uninstall absence checks are coherent and covered by driven installer tests. Both current checks pass.

Verdict: I agree with everything and have no additional feedback. Reviewed at `f1ada54`. The version/path gates, atomic current flips, safe flat-tree migration, effective-result assertions, and uninstall absence checks are coherent and covered by driven installer tests. Both current checks pass.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 19:46:21 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

First review at f1ada54. Faithful port of box#79's versioned layout with rig-shaped flip gate. CI green (check + db-integration). What I checked:

  • valid_version is real — forbids empty, leading ./-, and anything outside [A-Za-z0-9._+-] by construction; fires at every path-builder site (installer new_ver, migration flat_ver, use, single-version uninstall). Byte-identical copy in bin/rig and install.sh; suite diffs the two so they cannot drift (warn_bootstrapped same treatment).
  • Atomic current flipsln -sfn …current.new.$$ && mv -Tf everywhere; default judged from readlink -f versions/<v>, never a wedged link. Same-version re-run converges; RIG_REINSTALL=1 swaps by rename, delete last; non-current re-run never steals the default.
  • cmd_use asserts the effective resultcurrent resolves to the asked version AND the chain's own rig --version answers it.
  • Uninstall ends with absence assert — single-version and --all; rm exit is not the verdict; survivors scream INCOMPLETE by name. Consent is --force/RIG_YES/TTY or refuse. Current refused; dangling current → heal-before-delete. --all only removes PATH symlinks that resolve into this root.
  • Migration is safe in place — two renames in one parent; hostile flat VERSION refused before move; VERSION-less → 0.0.0-unknown.
  • Flip-gate divergence from box is justified — warn-and-proceed on /etc/rig/role (no strandable per-box state; upgrade is the normal case); RIG_ROLE_MARKER for fixtures.

Tests earn their keep: driven real install.sh runs against throwaway roots (fresh/converge/reinstall/side-by-side/use/migration/hostile VERSION/dangling current/uninstall matrix/consent/INCOMPLETE). Nothing to add.

**Verdict: Approve** — I agree with this as-is. First review at `f1ada54`. Faithful port of box#79's versioned layout with rig-shaped flip gate. CI green (check + db-integration). What I checked: - **`valid_version` is real** — forbids empty, leading `.`/`-`, and anything outside `[A-Za-z0-9._+-]` by construction; fires at every path-builder site (installer `new_ver`, migration `flat_ver`, `use`, single-version uninstall). Byte-identical copy in `bin/rig` and `install.sh`; suite diffs the two so they cannot drift (`warn_bootstrapped` same treatment). - **Atomic `current` flips** — `ln -sfn …current.new.$$ && mv -Tf` everywhere; default judged from `readlink -f versions/<v>`, never a wedged link. Same-version re-run converges; `RIG_REINSTALL=1` swaps by rename, delete last; non-current re-run never steals the default. - **`cmd_use` asserts the effective result** — `current` resolves to the asked version AND the chain's own `rig --version` answers it. - **Uninstall ends with absence assert** — single-version and `--all`; rm exit is not the verdict; survivors scream `INCOMPLETE` by name. Consent is `--force`/`RIG_YES`/TTY or refuse. Current refused; dangling current → heal-before-delete. `--all` only removes PATH symlinks that resolve into this root. - **Migration is safe in place** — two renames in one parent; hostile flat `VERSION` refused before move; VERSION-less → `0.0.0-unknown`. - **Flip-gate divergence from box is justified** — warn-and-proceed on `/etc/rig/role` (no strandable per-box state; upgrade is the normal case); `RIG_ROLE_MARKER` for fixtures. Tests earn their keep: driven real `install.sh` runs against throwaway roots (fresh/converge/reinstall/side-by-side/use/migration/hostile VERSION/dangling current/uninstall matrix/consent/INCOMPLETE). Nothing to add.
dan-claude-bot commented 2026-07-18 19:57:29 +00:00 (Migrated from github.com)

Review sweep, round 1 (codex approved / grok approved / claude -agree, comment-only by config): unanimous, no changes requested by anyone — nothing to push. Requesting @danmt for final review.

Review sweep, round 1 (codex ✅ approved / grok ✅ approved / claude ✅-agree, comment-only by config): unanimous, no changes requested by anyone — nothing to push. Requesting @danmt for final review.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#36
No description provided.