rig/bin/rig

488 lines
20 KiB
Text
Raw Normal View History

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
log() { printf 'rig: %s\n' "$*"; }
warn() { printf 'rig: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig: ERROR: %s\n' "$*" >&2; exit 1; }
version() { echo "rig $(cat "$ROOT/VERSION" 2>/dev/null || echo unknown) ($ROOT)"; }
usage() {
cat <<'EOF'
usage: rig <command> [args]
commands:
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored rig builds two kinds of thing on opposite sides of a trust boundary -- tailnet machines it converges, and guests a box mints -- and both families lived in one flat namespace with nothing in a role name saying which you meant. `staging` is where that stopped being cosmetic: the word names the metal that hosts guests and the guests on it, only one could have it, and #31 gave it to the guests. The VM-host shape was left nameless, spelled `custom --class server --host yes --join authkey`, which is what every refusal recited at an operator who had confused the two. The suffix now names the family: control-plane-server, workload-server, runner-server, dev-server, plus the restored staging-server (class=server host=yes join=authkey). host=yes already installs the box CLI and runs box's setup-host, so staging-server is a table row, not new machinery. It stays OUT of the tag:server allow-list deliberately -- a host is never managed by the control plane, its guests are -- so its key is minted tag:local. custom and workstation keep bare names as the rule, not an exception to it: custom presets nothing and can be any shape including a guest, so a family claim is one it cannot make; a workstation is somebody's own device, joined by interactive login, user-owned and untagged, never tailnet-managed. Hard cut, no aliases -- old names are refused as unknown. Two consequences this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name, so a box taking the default now comes up control-plane-server. And the two coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so they now look for role=control-plane-server; a pre-rename control plane takes their warning branch, which is advisory and never a gate, so the run proceeds and the message names the repair. dev-server is class=human, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy. The two axes share the word "server", which is a real wart -- #77 renames the class trait to what it controls, kept separate because it reaches markers on live machines that guard root SSH. Tests cover both directions of the cut: every new name resolves, every old name is refused as unknown, and the two deliberately-bare roles are proven NOT to have been swept up -- the inverse error, which would otherwise only surface at somebody's laptop. Closes #76 (machine-role half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
bootstrap <control-plane-server|workload-server|runner-server|staging-server|
dev-server|workstation|custom>
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
(--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
tailscale join, then this box's operators. --users is REQUIRED on
every role — it converges the users file as bootstrap's last phase
(identical to 'rig users apply --file <path>', marker written first)
so one command leaves a box with its people on it; --no-users is the
deliberate opt-out and leaves root as the only door. The tenant roles
below take neither. Roles are presets over the three traits; any flag
overrides its trait, and custom states all of them. Prompts for a
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
prompt); the key's tags are the tailnet tag, verified after join —
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored rig builds two kinds of thing on opposite sides of a trust boundary -- tailnet machines it converges, and guests a box mints -- and both families lived in one flat namespace with nothing in a role name saying which you meant. `staging` is where that stopped being cosmetic: the word names the metal that hosts guests and the guests on it, only one could have it, and #31 gave it to the guests. The VM-host shape was left nameless, spelled `custom --class server --host yes --join authkey`, which is what every refusal recited at an operator who had confused the two. The suffix now names the family: control-plane-server, workload-server, runner-server, dev-server, plus the restored staging-server (class=server host=yes join=authkey). host=yes already installs the box CLI and runs box's setup-host, so staging-server is a table row, not new machinery. It stays OUT of the tag:server allow-list deliberately -- a host is never managed by the control plane, its guests are -- so its key is minted tag:local. custom and workstation keep bare names as the rule, not an exception to it: custom presets nothing and can be any shape including a guest, so a family claim is one it cannot make; a workstation is somebody's own device, joined by interactive login, user-owned and untagged, never tailnet-managed. Hard cut, no aliases -- old names are refused as unknown. Two consequences this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name, so a box taking the default now comes up control-plane-server. And the two coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so they now look for role=control-plane-server; a pre-rename control plane takes their warning branch, which is advisory and never a gate, so the run proceeds and the message names the repair. dev-server is class=human, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy. The two axes share the word "server", which is a real wart -- #77 renames the class trait to what it controls, kept separate because it reaches markers on live machines that guard root SSH. Tests cover both directions of the cut: every new name resolves, every old name is refused as unknown, and the two deliberately-bare roles are proven NOT to have been swept up -- the inverse error, which would otherwise only surface at somebody's laptop. Closes #76 (machine-role half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
only control-plane-server and workload-server may carry tag:server.
The '-server' suffix names the FAMILY (a fleet machine, vs the '-box'
tenant roles); custom and workstation take none. join=login
(workstation) needs no key: interactive login, node must come up
untagged. Run as root.
bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
The box TENANT roles: converge a box-minted guest. The '-box' suffix
names the family (a guest, vs the '-server' machine roles above).
claude-box|codex-box|grok-box|kimi-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31) box templates collapse to thin, creds-free seeds (box#81); everything a tenant machine BECOMES moves here, as convergent, re-runnable roles with effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized per tenant through a pure lib (tenant-config.sh) — never four copies — dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single entrypoint. The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's CLI on the SYSTEM path (box exec shells read no rc files, #15), and the agent-context file — rendered from ONE shared template that carries the box#80 guard note once: never run box setup-host or the drill inside a box; the box you are in is not a host you own. staging lands box#69's server posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim from bootstrap.sh so both families converge ONE drop-in with one converger; its tailnet workload join stays operator-run, exactly the creds split #69 designed. Everything is asserted on effective state: the CLI must ANSWER as the tenant user (the grok template's linked-but-cannot-run scar), docker must answer, sshd -T must resolve. 'staging' therefore moves from the VM-host preset to the tenant role — the thing box#81's seed will auto-run. The host shape lost nothing: it is 'dev --class server' (or custom with all three traits), the catch-all effective-tag refusal still owns its tag policy, and a pre-#31 staging host re-running its old command gets a loud refusal naming the new spelling — tenants refuse host=yes boxes, agents refuse any machine-role box, staging tolerates the workload-joined guest and leaves its marker alone. Harness: the arg/refusal surface, the marker guards off fixture markers, the pure parameter table, the rendered context file (guard included, all three agents), creds-free-by-absence greps (no tailscale, no prompt), the CLI-verified-not-trusted pin, marker-after-converge ordering, and the re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
the system PATH, and the agent-context file (with the box#80 guard:
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
never run box setup-host inside a box). staging-box lands box#69's
server posture — docker + sshd hardening; its tailnet join stays
operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31) box templates collapse to thin, creds-free seeds (box#81); everything a tenant machine BECOMES moves here, as convergent, re-runnable roles with effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized per tenant through a pure lib (tenant-config.sh) — never four copies — dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single entrypoint. The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's CLI on the SYSTEM path (box exec shells read no rc files, #15), and the agent-context file — rendered from ONE shared template that carries the box#80 guard note once: never run box setup-host or the drill inside a box; the box you are in is not a host you own. staging lands box#69's server posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim from bootstrap.sh so both families converge ONE drop-in with one converger; its tailnet workload join stays operator-run, exactly the creds split #69 designed. Everything is asserted on effective state: the CLI must ANSWER as the tenant user (the grok template's linked-but-cannot-run scar), docker must answer, sshd -T must resolve. 'staging' therefore moves from the VM-host preset to the tenant role — the thing box#81's seed will auto-run. The host shape lost nothing: it is 'dev --class server' (or custom with all three traits), the catch-all effective-tag refusal still owns its tag policy, and a pre-#31 staging host re-running its old command gets a loud refusal naming the new spelling — tenants refuse host=yes boxes, agents refuse any machine-role box, staging tolerates the workload-joined guest and leaves its marker alone. Harness: the arg/refusal surface, the marker guards off fixture markers, the pure parameter table, the rendered context file (guard included, all three agents), creds-free-by-absence greps (no tailscale, no prompt), the CLI-verified-not-trusted pin, marker-after-converge ordering, and the re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
auto-runs these at mint. Run as root, inside the box.
coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
feat(coolify): install the control-plane dump as a systemd timer The Coolify control-plane database holds the GitHub App private key, every registered server's SSH key, and every environment value for every environment it manages. Backing it up was a manual runbook step, and the dump script lived in cast — the off-box tool, whose src never references it. It runs on the box, as root, under a scheduler: that is rig's job description. It matters beyond tidiness. The dump is forensics, not a restore path — a lost control plane is rebuilt fresh and reconciled from the manifest. So there will be a next control-plane box, and as a runbook step it was born un-backed-up, depending on someone remembering mid-incident. Now it is backed up from birth. rig installs the machinery and templates /etc/coolify-dump.env empty at 0600, never reading it back — no credential passes through rig. The script's own guards make an unfilled file fail the unit loudly rather than ship plaintext. systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets, failures surface in systemctl status instead of being mailed into the void, and Persistent=true catches a run missed while the box was down. Two hazards the cast script missed, carried into the unit: - aws-cli >= 2.23 enables default upload checksums that S3-compatible backends reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to when_required. - A failed pg_dump piped into age still yields a valid, tiny, encrypted file that uploads cleanly every night and looks exactly like a working backup. The script now refuses to upload an empty artifact. Closes #8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
coolify backup install [options]
Nightly age-encrypted dump of the control-plane database, as a
systemd timer. rig installs the machinery and templates an empty
0600 bindings file; you fill in the age recipient and S3 details.
Control-plane box only. Run as root.
feat(db): bring ad-hoc dump/restore on-box as `rig db` Add `rig db dump <container> [outfile]` and `rig db restore <artifact> <container> [db] [--yes]` — imperative on-box PostgreSQL tooling, the interactive counterpart to the scheduled, declarative `coolify backup install`. Key decisions: - Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner --no-acl` is mandatory for cross-instance restores: the target's superuser differs (Coolify randomizes it), so a plain dump aborts under ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role. - $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted `sh -c`), never hardcoded to `postgres` on the host. - restore connects as the container's own superuser and runs with ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a shared container, passed in via a container env var rather than string splicing. - restore overwrites the target, so it prompts y/N; --yes/--force is the automation bypass. Artifact existence/non-emptiness is checked before the confirm gate and before anything touches the DB. - dump uses pipefail + a sibling temp promoted only on success, and refuses to keep an empty artifact — a failed pg_dump must never leave a plausible-looking .gz behind. Args are validated before the root check (testable without root); guards are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and a `### rig db` README section. Closes #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
db <dump|restore> ...
Ad-hoc PostgreSQL dump/restore for a container on this box. `dump`
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
onto a different instance); `restore` loads one back, connecting as
the container's own superuser, behind a confirm gate. Run as root.
feat: rig platform — what is this machine, computed not stored rig read no hardware at all. The single exception was `uname -m` in runner-install.sh, used to pick a runner tarball and then discarded — so "is this the 32GB one, or the M900?" was a question you answered by logging in and running free -h, nproc, df -h and uname -r by hand, four commands deep, on a machine you were already unsure about. `rig platform` prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a provenance block: which rig, when, and the role marker's traits. It COMPUTES rather than stores, and that is the design rather than an implementation detail. Specs change without rig doing anything — RAM added, root disk resized, the unattended-upgrades bootstrap itself enables patching the kernel — so a stored spec is stale the moment the machine changes, and refreshing one on every run would collide with bootstrap's "safe to re-run; a second run changes nothing" contract. Nothing is written, so nothing can go stale. The corollary is deliberate: reading only /proc, uname, /etc/os-release, df and systemd-detect-virt means no root, no network, and it runs on a pristine Debian box rig has never bootstrapped — useful for deciding what to converge a machine into, not only for auditing it afterwards. That also makes it the rare rig command the harness can RUN for real rather than grep: the tests assert the answer describes the actual test machine (kernel and hostname compared against independently computed values), and assert it writes nothing. Both known traps are handled explicitly. /etc/os-release is sourced in a SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber same-named script variables, the form every other site in this tree uses and test/cli.sh already greps for. systemd-detect-virt exits non-zero on bare metal while printing 'none', a normal answer that set -e would otherwise turn into a failed run, so it is wrapped in `|| true`. Provenance is read, never written, and degrades per file. /etc/rig/manifest is #61 and does not exist yet, so that line reads 'not bootstrapped' on every machine today; the command ships complete without it and neither blocks the other. Named `platform` and not `status`: `users status` and `runner status` cross-check recorded against live state and print DRIFT, and a command that records nothing cannot drift, so calling it status would borrow a promise it structurally cannot make. It also leaves `rig status` free for the machine-wide roll-up it will eventually want to be. Refs #64
2026-07-19 23:35:58 +00:00
platform
What is this machine: hostname, OS, kernel, CPU, memory, disk and
virtualization, then rig's own provenance (which rig, when, and the
role marker bootstrap wrote). Computed at run time from /proc, uname,
/etc/os-release, df and systemd-detect-virt and stored NOWHERE — a
spec goes stale the moment someone adds RAM, so there is nothing to
go stale here. Writes nothing, needs no root, makes no network call,
and therefore also runs on a pristine Debian box rig has never
bootstrapped, where the provenance block reads 'not bootstrapped'.
runner install --repo <owner/repo> [options]
GitHub Actions runner as a systemd service under an unprivileged
user — outbound-only, no Docker. Prompts for the short-lived
registration token (RUNNER_TOKEN env overrides). Run as root.
runner status [--user <name>]
What this box's runner is registered to: repo, name, labels, unit.
Reads the box only — no token, no network call. Run as root.
runner remove [--local] [--user <name>]
Take the service down and deregister the runner. Prompts for the
short-lived removal token (RUNNER_REMOVE_TOKEN env overrides).
Run as root.
runner repoint --repo <owner/repo> [options]
Move an installed runner to another repository — deregister, then
re-register, reusing the binary already on the box. Needs a removal
token for the old repo and a registration token for the new one.
Run as root.
users apply --file <path>
Converge named operator accounts from a declarative users file, on
every class: groups by role (admin/rig/box), passwords locked always,
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
from the file are locked, never deleted. '-' reads stdin. Run as root.
users status
Roles (derived from actual group membership), key counts and lock
state for the rig-managed users. Reads the box only. Run as root.
users close-root
Shut root SSH on a class=human box once an admin key works. Refuses
on class=server — root there is the control plane's automation door —
and while no admin holds a key. Run as root.
manifest [<key>]
Print /etc/rig/manifest — which rig converged this machine and when
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
name the latest). With a key, print that value alone for shell callers.
Written by 'rig bootstrap' only; read-only here, and no root needed.
Provenance is DECIDED, so it is stored; the machine's specs are
OBSERVED, so they are not — those are 'rig platform'.
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
versions
List the installed rig versions — install.sh lands each one side by
side at <root>/versions/<v>, a 'current' symlink tracks the default
(what the rig on your PATH runs). The default is marked (current);
the tree answering this command is marked (running).
use <version>
Switch the default rig version — repoint 'current' (and the PATH
symlink riding it) at an installed version, atomically, then assert
the flip took. WARNS on a bootstrapped host (/etc/rig/role exists):
switching the rig under a converged host changes what a re-converge
would do.
uninstall [<version>|--all] [--force]
Remove one NON-current installed version, or --all: every version,
'current', and the PATH symlinks. Asks first (--force or RIG_YES=1
skips the prompt) and ENDS with an absence assert — every removed
path is re-checked, and any survivor makes it exit 1 naming the
leftovers instead of reporting a clean uninstall that wasn't.
--version
Print the running rig's version (its tree's own VERSION file) and
where it runs from.
install/upgrade:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
tracks the development tree). Re-run any time: an installed version
converges (no-op), a new one installs side by side at
<root>/versions/<v> and becomes the default.
EOF
}
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# --- the versioned install (box#79's layout, ported — #35) -------------------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/rig pointing through it.
# $ROOT (readlink -f, line 4) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
local vdir; vdir="$(dirname "$ROOT")"
[ "$(basename "$vdir")" = versions ] || return 1
dirname "$vdir"
}
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/rig's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
case "$1" in
''|.*|-*) return 1 ;;
*[!A-Za-z0-9._+-]*) return 1 ;;
esac
return 0
}
# The flip gate, rig's shape (#35): box refuses version flips under existing
# boxes; rig's stake is the converged HOST — /etc/rig/role marks a box that
# bootstrap has made into something. Switching the default rig under it
# changes what a re-converge would do, which is worth a warning, not a
# refusal: there is no user state a flip can strand, and flipping versions on
# a bootstrapped host is the normal upgrade. RIG_ROLE_MARKER overrides the
# path so tests point it at fixtures (repo precedent: the coolify marker
# gate). install.sh carries a byte-identical copy; test/cli.sh diffs the two.
warn_bootstrapped() { # $1 = what is about to happen
local marker="${RIG_ROLE_MARKER:-/etc/rig/role}"
[ -e "$marker" ] || return 0
warn "this host is bootstrapped ($(head -n1 "$marker" 2>/dev/null || echo "role marker at $marker"))"
warn "$1 changes what a re-converge (rig bootstrap, users apply) would do — proceeding."
}
# The PATH symlinks that could ride this install: the one this invocation came
# in on, RIG_BIN's, and the tier default's. Candidates only — every consumer
# checks where a link actually points before touching it, so a symlink that is
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
bin_links() {
local c=()
[ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
[ -n "${RIG_BIN:-}" ] && c+=("$RIG_BIN/rig")
if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/rig); else c+=("$HOME/.local/bin/rig"); fi
printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
}
converge_bin_links() { # $1 = install root: point our PATH symlinks through current
local ir="$1" p t
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in
"$ir"/*) ln -sfn "$ir/current/bin/rig" "$p" ;;
esac
done < <(bin_links)
}
cmd_versions() {
local ir cur d v mark
ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to list"
cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
echo "VERSIONS ($ir)"
for d in "$ir/versions"/*/; do
[ -d "$d" ] || continue
v="$(basename "$d")"
mark=""
[ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
[ "$(readlink -f "$d")" = "$ROOT" ] && mark="$mark (running)"
printf ' %s%s\n' "$v" "$mark"
done
echo
echo "switch the default: rig use <version>"
echo "install another: re-run install.sh (versions land side by side)"
}
cmd_use() {
local v="${1:-}" ir eff expect out
if [ -z "$v" ]; then
printf 'rig: use needs a version (see: rig versions)\n' >&2
usage >&2
exit 2
fi
ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to switch"
valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'rig versions')"
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'rig versions')"
warn_bootstrapped "switching the default rig version to $v"
# An atomic flip, not unlink+create: ln -sfn leaves a window where current
# is missing; a rename over it does not.
ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
converge_bin_links "$ir"
# Assert the EFFECTIVE result, not the intent: current must resolve to the
# version asked for, and the chain's own binary must answer that version —
# a flip that "worked" while the operator's rig still runs the old tree is
# exactly the flakiness this verb exists to end.
eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
[ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
if [ -n "$expect" ]; then
out="$("$ir/current/bin/rig" --version 2>&1 || true)"
case "$out" in
*"$expect"*) : ;;
*) die "current/bin/rig answers '$out', not version $expect — the symlink chain is broken" ;;
esac
fi
log "switched to $v (current -> versions/$v)"
}
# The uninstall's own confirmation: --force, or RIG_YES=1, or a TTY. RIG_YES
# is the installer-family consent contract — how automation says yes without
# a terminal; without any of the three we refuse rather than assume consent.
uninstall_confirm() { # $1 = question
[ "$force" -eq 1 ] && return 0
[ -n "${RIG_YES:-}" ] && return 0
if [ ! -t 0 ]; then
printf 'rig: refusing to %s without --force (no terminal to confirm on; RIG_YES=1 also means yes)\n' "$1" >&2
exit 2
fi
local reply
printf 'rig: %s? [y/N] ' "$1"
fix: uninstall_confirm swallows Ctrl-D — the abort was silent uninstall_confirm() read the operator's answer unguarded: read -r reply case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac bin/rig runs under `set -euo pipefail`, and both call sites (the single-version and the --all confirms) invoke the function as a plain statement — nothing suppresses errexit. Ctrl-D makes `read` return non-zero, so the shell died AT THE READ and the case on the next line was never evaluated: `die "aborted."` could not fire. The operator saw the question, pressed Ctrl-D, and got nothing — no message, exit 1, at exactly the moment the tool had asked whether to delete their install. It failed closed, so nothing was ever wrongly removed; the damage was that rig went silent at the one moment silence is unreadable. The fix is `read -r reply || reply=""` — commands/db.sh:152's spelling for the identical [y/N] confirm one file away. Empty routes through the existing `*)` arm, so EOF aborts through the same path a bare Enter already does: exactly one "aborted." message, no second die to keep in sync. test/cli.sh gains the first drills of the interactive path, which was structurally untested (every existing uninstall check goes through --force or RIG_YES, which is why this survived): `y` and Ctrl-D driven through a real pty via util-linux `script`, guarded by a command -v skip. They assert the MESSAGE, never the exit code — the unfixed code also exits 1, so an exit-code assertion is green against the bug. Mutation-verified: with `|| reply=""` reverted, 403 passed / 1 failed, the single failure being `output missing 'aborted.'`; restored, 404 passed / 0 failed. Refs #68
2026-07-19 23:32:24 +00:00
# `|| reply=""` is load-bearing under `set -e`: Ctrl-D makes read return
# non-zero, and as a plain-statement call site this function would die HERE,
# before the case — the abort would be real but completely silent. Empty
# falls through to the `*)` arm, so EOF aborts with exactly one message,
# the same spelling commands/db.sh uses for the same [y/N] shape.
read -r reply || reply=""
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
# 'rig uninstall' — the real uninstall, replacing the undocumented rm -rf
# prose. Trees and symlinks, and it ENDS by PROVING the absence — the last
# word is a re-check, not a hope.
cmd_uninstall() {
local ir a ver="" all=0 force=0 cur p t leftover=""
local targets=()
for a in "$@"; do
case "$a" in
--all) all=1 ;;
--force) force=1 ;;
-*)
printf 'rig: unknown option: %s\n' "$a" >&2
usage >&2
exit 2
;;
*)
if [ -n "$ver" ]; then
printf 'rig: uninstall takes one version, or --all\n' >&2
usage >&2
exit 2
fi
ver="$a"
;;
esac
done
ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
[ -w "$ir" ] || die "cannot write $ir — uninstall as the user that installed it (or root: sudo rig uninstall)"
# -- one version -----------------------------------------------------------
if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'rig versions')"
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'rig versions')"
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
# A broken current makes the CURRENT guard below unfireable (cur empty
# when the link is missing; cur naming a non-directory when it dangles —
# readlink -f resolves a link whose last component does not exist). Heal
# first, then decide; never delete around a broken default.
{ [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
|| die "current is dangling — 'rig use <version>' to repoint the default first (refusing to remove versions while it is broken)"
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'rig use <other>' first, or 'rig uninstall --all' for everything"
uninstall_confirm "remove rig version $ver from $ir"
# rm's exit code is not the verdict — the absence re-check below is (a
# half-removed tree must be reported as INCOMPLETE, not as a crash).
rm -rf "${ir:?}/versions/$ver" || true
if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
echo "rig: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
exit 1
fi
log "removed version $ver (the default stays $cur)"
return 0
fi
if [ -n "$ver" ]; then
printf 'rig: a version and --all together is ambiguous\n' >&2
usage >&2
exit 2
fi
# -- everything (bare 'rig uninstall' and '--all' both mean all of it) -----
warn_bootstrapped "removing rig entirely"
uninstall_confirm "remove the ENTIRE rig install at $ir (every version)"
# The removal set, gathered BEFORE anything is deleted, so the absence
# assert below re-checks exactly what was promised gone. PATH symlinks are
# removed only when they resolve into (or dangle at) THIS install root.
targets+=("$ir")
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in "$ir"/*) targets+=("$p") ;; esac
done < <(bin_links)
mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')
# rm's exit code is not the verdict — the absence assert below is (a
# half-removed tree must be reported as INCOMPLETE by name, not as a crash).
for p in "${targets[@]}"; do rm -rf "$p" || true; done
# END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
# A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
# get verified.
for p in "${targets[@]}"; do
if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
done
if [ -n "$leftover" ]; then
echo "rig: uninstall INCOMPLETE — still present:$leftover" >&2
echo "rig: remove them by hand, and re-check each path is really gone." >&2
exit 1
fi
echo "rig: uninstalled — removed:"
for p in "${targets[@]}"; do echo "rig: · $p"; done
}
cmd="${1:-}"
case "$cmd" in
bootstrap)
shift
exec "$ROOT/commands/bootstrap.sh" "$@"
;;
coolify)
shift
sub="${1:-}"
feat(coolify): install the control-plane dump as a systemd timer The Coolify control-plane database holds the GitHub App private key, every registered server's SSH key, and every environment value for every environment it manages. Backing it up was a manual runbook step, and the dump script lived in cast — the off-box tool, whose src never references it. It runs on the box, as root, under a scheduler: that is rig's job description. It matters beyond tidiness. The dump is forensics, not a restore path — a lost control plane is rebuilt fresh and reconciled from the manifest. So there will be a next control-plane box, and as a runbook step it was born un-backed-up, depending on someone remembering mid-incident. Now it is backed up from birth. rig installs the machinery and templates /etc/coolify-dump.env empty at 0600, never reading it back — no credential passes through rig. The script's own guards make an unfilled file fail the unit loudly rather than ship plaintext. systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets, failures surface in systemctl status instead of being mailed into the void, and Persistent=true catches a run missed while the box was down. Two hazards the cast script missed, carried into the unit: - aws-cli >= 2.23 enables default upload checksums that S3-compatible backends reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to when_required. - A failed pg_dump piped into age still yields a valid, tiny, encrypted file that uploads cleanly every night and looks exactly like a working backup. The script now refuses to upload an empty artifact. Closes #8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
case "$sub" in
install)
shift
exec "$ROOT/commands/coolify-install.sh" "$@"
;;
backup)
shift
if [ "${1:-}" != "install" ]; then
usage >&2
exit 2
fi
shift
exec "$ROOT/commands/coolify-backup-install.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
feat(db): bring ad-hoc dump/restore on-box as `rig db` Add `rig db dump <container> [outfile]` and `rig db restore <artifact> <container> [db] [--yes]` — imperative on-box PostgreSQL tooling, the interactive counterpart to the scheduled, declarative `coolify backup install`. Key decisions: - Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner --no-acl` is mandatory for cross-instance restores: the target's superuser differs (Coolify randomizes it), so a plain dump aborts under ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role. - $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted `sh -c`), never hardcoded to `postgres` on the host. - restore connects as the container's own superuser and runs with ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a shared container, passed in via a container env var rather than string splicing. - restore overwrites the target, so it prompts y/N; --yes/--force is the automation bypass. Artifact existence/non-emptiness is checked before the confirm gate and before anything touches the DB. - dump uses pipefail + a sibling temp promoted only on success, and refuses to keep an empty artifact — a failed pg_dump must never leave a plausible-looking .gz behind. Args are validated before the root check (testable without root); guards are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and a `### rig db` README section. Closes #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
db)
shift
case "${1:-}" in
dump|restore|-h|--help)
exec "$ROOT/commands/db.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
feat: rig platform — what is this machine, computed not stored rig read no hardware at all. The single exception was `uname -m` in runner-install.sh, used to pick a runner tarball and then discarded — so "is this the 32GB one, or the M900?" was a question you answered by logging in and running free -h, nproc, df -h and uname -r by hand, four commands deep, on a machine you were already unsure about. `rig platform` prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a provenance block: which rig, when, and the role marker's traits. It COMPUTES rather than stores, and that is the design rather than an implementation detail. Specs change without rig doing anything — RAM added, root disk resized, the unattended-upgrades bootstrap itself enables patching the kernel — so a stored spec is stale the moment the machine changes, and refreshing one on every run would collide with bootstrap's "safe to re-run; a second run changes nothing" contract. Nothing is written, so nothing can go stale. The corollary is deliberate: reading only /proc, uname, /etc/os-release, df and systemd-detect-virt means no root, no network, and it runs on a pristine Debian box rig has never bootstrapped — useful for deciding what to converge a machine into, not only for auditing it afterwards. That also makes it the rare rig command the harness can RUN for real rather than grep: the tests assert the answer describes the actual test machine (kernel and hostname compared against independently computed values), and assert it writes nothing. Both known traps are handled explicitly. /etc/os-release is sourced in a SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber same-named script variables, the form every other site in this tree uses and test/cli.sh already greps for. systemd-detect-virt exits non-zero on bare metal while printing 'none', a normal answer that set -e would otherwise turn into a failed run, so it is wrapped in `|| true`. Provenance is read, never written, and degrades per file. /etc/rig/manifest is #61 and does not exist yet, so that line reads 'not bootstrapped' on every machine today; the command ships complete without it and neither blocks the other. Named `platform` and not `status`: `users status` and `runner status` cross-check recorded against live state and print DRIFT, and a command that records nothing cannot drift, so calling it status would borrow a promise it structurally cannot make. It also leaves `rig status` free for the machine-wide roll-up it will eventually want to be. Refs #64
2026-07-19 23:35:58 +00:00
platform)
shift
exec "$ROOT/commands/platform.sh" "$@"
;;
runner)
shift
sub="${1:-}"
case "$sub" in
install)
shift
exec "$ROOT/commands/runner-install.sh" "$@"
;;
status)
shift
exec "$ROOT/commands/runner-status.sh" "$@"
;;
remove)
shift
exec "$ROOT/commands/runner-remove.sh" "$@"
;;
repoint)
shift
exec "$ROOT/commands/runner-repoint.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
users)
shift
sub="${1:-}"
case "$sub" in
apply)
shift
exec "$ROOT/commands/users-apply.sh" "$@"
;;
status)
shift
exec "$ROOT/commands/users-status.sh" "$@"
;;
close-root)
shift
exec "$ROOT/commands/users-close-root.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
manifest)
shift
exec "$ROOT/commands/manifest.sh" "$@"
;;
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
versions)
shift
cmd_versions "$@"
;;
use)
shift
cmd_use "$@"
;;
uninstall)
shift
cmd_uninstall "$@"
;;
-V|--version)
version
exit 0
;;
-h|--help|help)
usage
exit 0
;;
"")
usage >&2
exit 2
;;
*)
printf 'rig: unknown command: %s\n' "$cmd" >&2
usage >&2
exit 2
;;
esac