rig/bin/rig
cluade-reviewer-andresmgsl 903d8371b3
Some checks failed
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.

The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.

ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.

rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.

Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.

Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.

bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.

Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00

553 lines
23 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
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:
bootstrap <control-plane-server|workload-server|runner-server|staging-server|
dev-server|workstation|custom>
(--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,
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 —
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 --undo
Leave the tailnet only when /etc/rig/role proves rig performed the
join, then remove that marker. Refuses for pre-existing or old unknown
joins, and while a GitHub runner is installed. Run as root.
bootstrap <role>-box [--user <name>]
The box TENANT roles: converge a box-minted guest. The '-box' suffix
names the family (a guest, vs the '-server' machine roles above).
The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are
DEFINED in the heavy-duty/rig-templates registry — resolved via
RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land
the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the
system PATH, and the agent-context file (with the box#80 guard:
never run box setup-host inside a box). staging-box, in rig's own
tree, 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 auto-runs these at mint. Run as
root, inside the box.
coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
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.
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.
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.
forgejo-runner install --instance <url> [options]
Forgejo Actions runner as a systemd service under an unprivileged
user — outbound-only, jobs in containers on this box's own dockerd.
Its home is a ci-box tenant, where 'rig bootstrap ci-box' already
installed that daemon. Registers to an INSTANCE: whether the runner
serves that instance, one org or one repo is a property of the
registration token, so there is no --repo. Prompts for the token
(FORGEJO_RUNNER_TOKEN env overrides). Run as root.
forgejo-runner status [--user <name>]
Which instance this box's Forgejo runner is registered to: instance,
name, labels, unit. Reads the box only — no token, no network call,
and never prints the registration secret .runner holds. Run as root.
forgejo-runner remove [--user <name>]
Take the service down and wipe this box's registration. Always
local-only: Forgejo has no deregistration endpoint, so the runner
stays listed offline until you delete it in the instance. Needs no
token. 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.
template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates
shape): template.env against the allowlist (data, never sourced),
install.sh present with a shebang, creds.md non-blank. Every refusal
names the failing key. The registry repo's CI runs this on every PR;
no root, no network, no writes.
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'.
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
}
# --- 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"
# `|| 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=""
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:-}"
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
;;
db)
shift
case "${1:-}" in
dump|restore|-h|--help)
exec "$ROOT/commands/db.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
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
;;
forgejo-runner)
shift
sub="${1:-}"
case "$sub" in
install)
shift
exec "$ROOT/commands/forgejo-runner-install.sh" "$@"
;;
status)
shift
exec "$ROOT/commands/forgejo-runner-status.sh" "$@"
;;
remove)
shift
exec "$ROOT/commands/forgejo-runner-remove.sh" "$@"
;;
repoint)
# The GitHub sibling HAS this verb, so an operator will try it. Say why
# it cannot exist here rather than printing usage and leaving them to
# infer it: 'repoint' is atomic because GitHub lets rig deregister from
# the old repo; Forgejo has no such endpoint, so the honest shape is
# two acts, and one of them leaves a stale entry behind.
printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance <url>", and delete the stale runner in the old instance under Actions > Runners.\n' >&2
exit 2
;;
*)
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
;;
template-lint)
shift
exec "$ROOT/commands/template-lint.sh" "$@"
;;
manifest)
shift
exec "$ROOT/commands/manifest.sh" "$@"
;;
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