Single source of truth for WHAT installs, HOW you select it, and HOW each tool is fetched. Read this before changing a role, adding a tool, or wiring a new persona.
Four levels, smallest to largest: tool -> group -> role -> persona. Every
level is selectable the same way from Ansible and from install.sh
(--tags <name>). The bare ./install.sh runs only the additive developer
base; everything else is opt-in. Every tool is fetched at whatever version is
current when the installer runs.
flowchart TD
P["persona<br/>opinionated bundle"] -->|resolves to| R["role<br/>building block"]
R -->|contains| G["group<br/>themed tag e.g. data, vpn-clients"]
G -->|contains| T["tool<br/>one binary/app"]
classDef persona fill:#0072B2,color:#fff,stroke:#333
classDef role fill:#009E73,color:#fff,stroke:#333
classDef group fill:#E69F00,color:#000,stroke:#333
classDef tool fill:#F0E442,color:#000,stroke:#333
class P persona
class R role
class G group
class T tool
Snipe any level directly: --tags ripgrep (tool), --tags data (group),
--tags infrastructure (role), --tags full-stack (persona). Everything is
additive, so levels combine: --soe --languages rust,go.
A persona is a meta-role: it carries no tasks, only meta/dependencies, so it
resolves identically in Ansible and the CLI. Pick one, or none (the default).
| Persona | Flag | Pulls in | For |
|---|---|---|---|
| default | (none) | developer base only |
anyone - a clean, additive CLI base |
| languages | --languages [langs] |
all toolchains, or a subset - --languages rust,go |
polyglot dev box |
| full-stack | --full-stack |
developer + gui + node + typescript + python + infrastructure | app dev, front-to-back |
| infra | --infra |
developer + infrastructure | SRE / platform |
| contributor | --contributor |
developer + contributor (CI toolchain) | outside contributor to a HyperI product |
| soe | --soe |
developer + contributor + soe + soe-gui | HyperI staff workstation |
Everything combines. Personas, roles, groups and tools are additive tags, so
you mix them freely: --soe --languages rust,go, --full-stack --tags infrastructure,
--infra --tags vscode. --languages takes an optional comma list for a subset
(--languages rust,go) or, bare, pulls every toolchain.
Persona wiring caveat. --tags developer OVERRIDES the never gate (the
role tag sits on every base task), so a persona must resolve through its own
meta-role tag, never by passing the raw developer tag - otherwise it drags in
chrome/removals/etc. The default no-tags run is unaffected and stays
additive.
Roles are the building blocks. The meta-dependency chain means selecting a higher role pulls the lower ones automatically - nobody memorises the list.
flowchart TD
soe["soe<br/>+ soe-gui"] --> contributor
contributor --> developer
developer --> astral["astral suite<br/>uv + ruff + ty"]
soegui["soe-gui"] --> astral
soegui --> rdpclient["rdp-client"]
dpy["developer-python"] --> astral
dts["developer-typescript"] --> dnode["developer-node"]
dlang["developer-languages"] --> drust["developer-rust"]
dlang --> dgo["developer-go"]
dlang --> dpy
dlang --> dnode
dlang --> dts
dlang --> dc["developer-c"]
classDef org fill:#0072B2,color:#fff,stroke:#333
classDef base fill:#009E73,color:#fff,stroke:#333
classDef lang fill:#E69F00,color:#000,stroke:#333
classDef dep fill:#CC79A7,color:#000,stroke:#333
class soe,soegui,contributor org
class developer base
class drust,dgo,dpy,dnode,dts,dc,dlang lang
class astral dep
| Role | Tag | Deps | Opt-in |
|---|---|---|---|
| developer | developer |
astral | default (runs bare) |
| developer-gui | developer-gui |
- | opt-in |
| developer-rust / -go / -node / -c | same | (node: -) | opt-in |
| developer-python | developer-python |
astral | opt-in |
| developer-typescript | developer-typescript |
developer-node | opt-in |
| developer-languages | developer-languages |
all developer-<lang> |
opt-in (meta) |
| infrastructure | infrastructure |
- | opt-in |
| packer | packer |
- | opt-in, in no persona |
| contributor | contributor |
developer | opt-in |
| soe | soe |
contributor | opt-in |
| soe-gui | soe-gui |
astral, rdp-client | opt-in |
| developer-ai | developer-ai |
- (probed, not declared) | opt-in |
| rdp-server | rdp-server |
- | opt-in |
| rdp-client | rdp-client |
- | opt-in; pulled by default via soe-gui |
| vpn-clients | vpn-clients |
- | opt-in / soe default |
| astral (uv + ruff + ty) | astral |
- | base component; also dep of python/soe-gui |
| bash-modern | bash-modern |
- | opt-in (macOS) |
| vm_optimizer | vm |
- | opt-in |
| system_cleanup | always |
- | always (cache-clean only) |
developer-ai's empty Deps column is not the same empty as the others. It has
three hard dependencies - Claude Code, the codex binary, node - and declares
none of them, because declaring them would be wrong twice over. Claude Code
comes only from soe, so depending on it drags HyperI org policy onto a box
that asked for a review tool; node comes from the developer base rather than
developer-node, so the dependency that looks right supplies nothing. Whether Claude Code is there depends on what the target user's PATH reaches, which no role graph can answer. The run probes the target for all three instead and names the tag that fixes each miss.
A group is a themed tag over several tools in one role, so you can take the set without the whole role.
| Group | Tag | Role | Tools |
|---|---|---|---|
| ai | ai |
developer-ai | Codex CLI, the Codex plugin for Claude Code |
| cloudflare | cloudflare |
infrastructure | flarectl, wrangler |
| data | data |
infrastructure | clickhouse-client, confluent-cli, rpk, valkey-cli, vector |
| forgejo | forgejo / codeberg |
soe | tea (Forgejo/Gitea CLI) |
| rdp-client | rdp-client |
rdp-client | Remmina (Linux), Thincast (macOS) |
| vpn-clients | vpn-clients |
vpn-clients | OpenVPN 3, WireGuard, Tunnelblick (macOS) |
Per-app tags stay too (--tags vscode, --tags slack, ...): run
./install.sh --list-apps for the full per-app list.
Method is how the tool is fetched. Every install resolves the current version
at run time, so there is no per-tool version policy to read here.
Digest verification is not universal. go and rustup fetch a checksum
from their publisher and verify it. The rest rest on HTTPS and the release tag,
which a publisher can in principle force-move. Extending checksum-at-download to
the other manual binaries is the outstanding hardening (hyperi-ci #66).
Three fetches verify a digest IN THIS REPO, and they are the ones whose SHA is held here or read from a published manifest: the Go toolchain, rustup-init, and macbash (whose .sha256 sits beside the asset on downloads.hyperi.io, so the fetch also re-pulls a republished binary). A fourth, the Codex CLI on Linux, DELEGATES the check to a vendor script this repo runs unverified. That is a weaker position than the other three and is counted separately on purpose. Claude Code on Linux comes from Anthropic's signed apt/dnf repository, whose key is pinned by fingerprint like the other vendor repos.
Codex is the one where what is verified matters. The installer script has no
published checksum of its own; what it verifies is its payload, the
codex-package-<target>.tar.gz release tarball, against the published
codex-package_SHA256SUMS before it unpacks anything. So the digest covers what
lands on disk, not the script that puts it there, and the script is re-fetched
and re-run unattended by the weekly hyperi-update timer on an soe box. That
check is still most of why the role runs the installer rather than fetching the
asset itself: the thin codex-<target>.tar.gz assets, the obvious thing to
reach for instead, are not in that manifest and have no published digest at all.
| Tool(s) | Platforms | Method |
|---|---|---|
astral suite: uv, ruff, ty (uv bundles uv audit + uv check) |
all | Fedora dnf / macOS brew; Ubuntu has no apt package (see Auto-update) |
Python 3.14 for uv (astral_python_version), pinned as uv's global default; system python3 untouched |
all | system python3 where it is 3.14+ (Fedora, Ubuntu 26.04), else uv python install; uv python pin --global per user |
| CLI utils (jq, bat, fzf, ripgrep, fd, git-delta, moreutils, miller, rsync, tmux, htop, wget, shellcheck, age, parallel, ...) | all | distro repo / brew |
| sd | all | distro (apt/dnf) / brew |
yq (mikefarah; apt yq is kislyuk/yq, a different tool) |
all | Fedora dnf / Ubuntu re-fetch (Tier 3) / brew |
| lazygit | all | Linux github-binary (Tier 3: re-fetch) / brew |
| docker (Engine on Linux, CLI-only on macOS) | all | vendor-repo / brew |
| git, git-lfs, git-filter-repo, gh | all | distro/PPA/brew |
chrome, brave (opt-in never) |
all | vendor-repo / cask |
| Homebrew (bootstrap) | macOS | vendor-script |
| Tool(s) | Platforms | Method |
|---|---|---|
| VS Code | all | vendor-repo / cask |
| Ghostty | Fedora (COPR) / macOS (cask) | vendor-repo / cask |
| Ghostty | Ubuntu | PPA (ppa:mkasberg/ghostty-ubuntu, key pinned by fingerprint); 24.04 is frozen at Ghostty 1.3.1 by the PPA, only 26.04 tracks new releases; a series the PPA does not publish takes the newest series it does |
| DBeaver | all | Ubuntu vendor-repo (dbeaver.io/debs) / Fedora flatpak / cask |
VS Code privacy profile (opt-in: -e vscode_privacy_enabled=true) |
all | bundled script (hyperi-vscode-privacy) |
The privacy profile is off by default because it edits a personal
settings.json. It merges one marked block of managed keys into VSCode,
VSCodium and Cursor, leaving every comment and unmanaged key untouched, backs
the file up once before its first write, and removes only its own keys under
-e vscode_privacy_uninstall=true. A key the developer sets later in the same
file keeps their value, and the run says which keys that applies to rather
than reporting the profile applied when it changed nothing.
One role per language (developer-<lang>), each its own tag. developer-languages
is the meta-role pulling them all.
| Tool(s) | Platforms | Method |
|---|---|---|
| rustup + stable toolchain, rustfmt, clippy | all | vendor-script + rustup |
| cargo-binstall | all | cargo |
| cargo-* (nextest, deny, chef, bacon, update) | all | cargo |
| cargo-audit, cargo-hack, cargo-pgo | all | cargo |
| cargo-machete (unused deps), cargo-semver-checks (API breaks) | all | cargo |
| cargo-llvm-cov + llvm-tools-preview | all | cargo / rustup |
| protobuf-compiler | Linux | distro repo |
| librdkafka1, librdkafka-dev (Fedora: librdkafka-devel) | Linux | vendor-repo (Confluent clients repo) |
| mold, clang | Linux | distro repo |
| sccache | all | github-binary, latest each run (Tier 3) / brew |
| cargo-sweep | all | cargo-binstall / cargo |
| hyperi-rust-setup, hyperi-rust-cache-prune | all | role file -> /usr/local/bin |
build governor (hyperi-rust-govern + rustbuild.slice) |
all | role file + user unit |
Every cargo tool is installed with --locked, and hyperi-update refreshes them
with cargo install-update -a --locked. cargo install ignores the crate's
published Cargo.lock by default and re-resolves, so without the flag a tool is
built against dependency versions its author never tested -- and an update pass
without it silently undoes the flag on every tool it touches.
cargo-binstall is installed for reaching for by hand. The tasks do not route
through it: it fetches a publisher's prebuilt binary, where a lockfile has no
meaning, so the defined --locked semantics of cargo install win over the
faster fetch.
Coverage is cargo-llvm-cov. It replaced cargo-tarpaulin, which is ptrace-based
and therefore Linux-x86_64 only -- a macOS developer had no coverage tool at all.
The role uninstalls a leftover tarpaulin, because hyperi-ci prefers it when both
are present and would otherwise keep Linux on the tool macOS cannot run.
Build cache caps are a sub-tag, rust-cache, so they can be applied without a
full toolchain converge:
./install.sh --tags rust-cacheThe build governor is the same shape (--tags rust-governor), and opt-OUT
(-e rust_governor_enabled=false removes it). Concurrent cargo/rustc across
several projects can OOM a host outright, so: the whole tree -- sccache-hosted
compiles included -- runs inside rustbuild.slice with memory capped as a
percentage of the host's RAM, at nice 19 and a CPU weight below the desktop's.
No core is withheld and no job count is set: builds take every core until
something else wants one. macOS has no cgroups and gets nice 19 alone -- no
hard memory ceiling. HYPERI_RUST_GOVERNOR=off bypasses one invocation.
The rust-cache tag installs both tools, writes the caps, and schedules the prune
(systemd timer on Linux, launchd agent on macOS). Sizes come from
rust_cache_* in the role defaults; a build box overrides them per host.
| Tool(s) | Platforms | Method |
|---|---|---|
| go, delve | all | distro repo / brew |
| gopls | all | go install |
| golangci-lint* (Tier 3: re-fetch) | all | github-binary / brew |
| gosec, govulncheck | all | Fedora dnf / Ubuntu go install / brew |
The base ships the Astral suite (uv, ruff, ty) and uv bundles uv audit /
uv check, so this role adds only the opt-in legacy type checker.
| Tool(s) | Platforms | Method |
|---|---|---|
mypy (opt-in; uv check/ty is the default) |
all | uv-tool |
| Tool(s) | Platforms | Method |
|---|---|---|
| eslint, prettier | all | npm global |
The runtime is NOT here. node, npm, semantic-release, pnpm and corepack live in
the developer base -- they are core tooling that semantic-release and CI need,
so the base installs them and this role adds only the linters. That matters
because it decides which tag fixes a missing node: developer, not
developer-node. developer-ai's plugin gate says so in its warning, and this
table used to say the opposite.
| Tool(s) | Platforms | Method |
|---|---|---|
| typescript, tsx | all | pnpm global |
| Tool(s) | Platforms | Method |
|---|---|---|
| C build tools (gcc, make, cmake, pkg-config) | Linux distro; macOS CLT | distro / xcode-select |
| Tool(s) | Platforms | Method |
|---|---|---|
| kubectl | all | vendor-repo (pkgs.k8s.io), the minor in infrastructure_kubectl_minor / brew |
| helm | all | Ubuntu vendor-repo (packages.buildkite.com) / Fedora dnf / brew |
| kubectx, kubens | all | Ubuntu apt universe / Fedora github-binary (Fedora packages neither) / brew |
| k9s | all | Fedora dnf / Ubuntu re-fetch (Tier 3) / brew |
| kind, argocd (Tier 3: re-fetch) | all | github-binary / brew |
| kustomize | all | Fedora dnf / Ubuntu re-fetch (Tier 3) / brew |
| checkov | all | uv-tool (Tier 2) / brew |
| terraform-docs (Tier 3: re-fetch) | all | github-binary / brew |
| aws-cli v2 | all | Fedora dnf (awscli2) / Ubuntu official snap / brew |
| aws-vault (Tier 3: re-fetch) | all | github-binary from the ByteNess fork / brew formula |
opentofu (tofu) |
all | vendor-repo (packages.opentofu.org), apt AND dnf / brew |
| openbao | all | vendor-repo (pkgs.openbao.org) / Fedora dnf / brew |
| azure-cli, google-cloud-cli | all | vendor-repo / cask |
clickhouse-client, confluent-cli, rpk, valkey-cli, vector (the data group) |
Linux; macOS partial | vendor-repo / distro |
wrangler (the cloudflare group) |
all | npm-global / brew |
flarectl (the cloudflare group) |
all | go install from source / brew |
Almost every macOS path resolves to brew or a cask. The language managers that
remain there carry no formula at all: alint and maid have none, and
semantic-release needs its plugin set installed alongside it, which only npm
gives.
Cloudflare publishes no flarectl binary and no distro packages it, so both
platforms build it from source. It also lives on cloudflare-go's v0 branch --
from v4 that SDK is generated and carries no cmd/ directory. The Linux build
needs a Go toolchain (--tags developer-go); without one the run records a
warning and continues.
| Tool(s) | Platforms | Method |
|---|---|---|
| packer | all | vendor-repo (apt.releases.hashicorp.com, rpm.releases.hashicorp.com), signing key fingerprint-pinned / brew (hashicorp/tap) |
The one HashiCorp tool installed: BUSL, with no open-source fork, so it is its own opt-in role rather than part of infrastructure. --tags removals takes terraform and vault, and removes this repo only where Packer is not installed.
* = blocking CI gate.
| Tool(s) | Platforms | Method |
|---|---|---|
| hyperi-ci, semgrep | all | uv-tool (Tier 2) / brew |
| alint | all | cargo (Tier 2) / brew |
| osv-scanner | all | Linux re-fetch (Tier 3) / brew |
| gitleaks* | all | Fedora dnf / Ubuntu re-fetch (Tier 3) / brew |
| act | all | Fedora COPR / Ubuntu re-fetch (Tier 3) / brew |
| trivy | all | vendor-repo (official aquasecurity apt/dnf) / brew |
| hadolint* | all | Linux re-fetch (Tier 3) / brew |
| pip-audit* | all | uv-tool (Tier 2) / brew |
| kubeconform*, kube-linter | all | github-binary (Tier 3: re-fetch) / brew |
| yamllint, ansible-lint, pre-commit | all | distro (apt universe / dnf) / brew |
| actionlint | all | Linux re-fetch (Tier 3) / brew |
| vulture | all | Ubuntu apt / Fedora uv-tool (Tier 2) / brew |
| typos | all | cargo (Tier 2) / brew |
maid (mermaid validator, used by /docs) |
all | npm global (Tier 2) |
| git-scrub (git-history scrubber) | all | github-binary (Tier 3: re-fetch) / brew tap |
| macbash (macOS bash portability checker) | all | Linux downloads.hyperi.io binary (Tier 3, digest-verified) / brew tap |
hyperi-ci is a Python tool from PyPI, installed via uv tool and refreshed to
the latest release on every run (upgrade-if-present, not install-once). soe
inherits it through its contributor dependency, so a staff box always has current
hyperi-ci.
| Tool(s) | Platforms | Method |
|---|---|---|
Claude Code CLI (binary, tag claude) |
Linux Anthropic apt/dnf repo, soe_claude_channel (default stable; key pinned by fingerprint) / macOS cask |
vendor-repo / cask |
Claude Code managed settings (tag claude-policy, soe only) |
all | role file -> /etc/claude-code/ |
tea (Forgejo/Gitea CLI, forgejo / codeberg tags; gh is GitHub-only) |
Linux github-binary / macOS brew | github-binary / brew |
| openvpn3 client (-> vpn-clients group) | Fedora COPR / Ubuntu vendor-repo / macOS brew | vendor-repo / brew |
| WireGuard, Tunnelblick (macOS) | all / macOS | distro / cask |
| Slack | all | vendor-repo / cask |
| LibreOffice (org office suite) | Linux | distro repo |
| Nemo, GNOME extensions (gext), fonts | Linux | distro / uv-tool / vendored |
colima + Apple container (macOS only; container needs Apple silicon and macOS 26) |
macOS | brew |
Arcane container UI (opt-in soe_arcane_enabled; soe_arcane_long_session for a year-long login) |
all | container image |
Local ClickHouse + Redpanda (opt-in soe_local_services_enabled) |
all | container image |
removals / update_command / admin-scripts (opt-in never, on for soe) |
Linux | tombstones + scripts |
| Tool(s) | Platforms | Method |
|---|---|---|
| Codex CLI | Linux | vendor-script (chatgpt.com/codex/install.sh; payload digest-verified) |
| Codex CLI | macOS | cask codex (there is no formula) |
| codex-plugin-cc, the Codex plugin FOR Claude Code | all | claude plugin install codex@openai-codex |
Claude-first by design. Claude Code stays the driver and Codex is the
adversarial reviewer, reached from inside Claude Code through OpenAI's own
plugin. Nothing in this role installs Claude Code - that is soe's
claude tag, which installs the BINARY AND NOTHING ELSE. The HyperI managed
settings that used to ride along with it now sit behind claude-policy, which
only soe selects, so --tags claude on a machine that is not ours no longer
writes an unoverridable settings file into /etc. Codex ships several stable
releases a week, so it moves faster than most of this matrix.
The plugin's dependencies are hard, and all three are per user: Claude Code, a
codex that answers both codex --version and codex app-server --help, and
node 18.18+ (its hooks run node <script>.mjs directly). Miss one and the run
SKIPS the plugin and names the tag that fixes it - claude, codex, or
developer for node, which is where node lives. Installing it regardless would
leave slash commands that throw on every invocation, and a converge reporting
success over them.
No context-compression proxy, deliberately. Headroom
(headroomlabs-ai/headroom) was built into this role and taken back out before it
shipped. It works by pointing ANTHROPIC_BASE_URL at a local proxy, and Claude
Code gates capabilities on api.anthropic.com: behind any custom base URL it
stops sending the context-1m beta header and accounts against 200k
(headroom #1158),
disables on-demand tool loading, inflating context by roughly 25K tokens
(#746), and disables
Remote Control outright
(#1779, open, quoting
Claude Code's own "Remote Control is only available when using Claude via
api.anthropic.com"). Trading a 1M window for token compression is a losing
trade. The 1M half is partly recoverable with a [1m]-suffixed model alias and
tool search with ENABLE_TOOL_SEARCH=true; Remote Control is not. Check whether
that gating still holds before anyone re-adds it.
Neither Codex nor the plugin works straight off a converge. Both need an
interactive codex login (codex login --device-auth where there is no
browser), which the deploy deliberately does not automate: it would put a
per-user credential in the deploy path of a public repo.
| Role | Tag | What it does |
|---|---|---|
power-profile |
power-profile |
Sleep, idle and lid policy. always-on (default) never sleeps on mains power, lid shut included, and leaves battery behaviour stock; vm never sleeps at all, for an unattended RDP guest. Select with -e power_profile=<name>. Profiles are data files under roles/power-profile/vars/profiles/ |
rdp-server |
rdp-server |
GNOME Remote Login on port 3389 -- the system-wide feature driven by grdctl --system, NOT the per-user Desktop Sharing on the other Settings tab. Never overwrites credentials GNOME already holds. Includes handover-watchdog, which restarts the daemon after a failed handover leaves it accepting connections and servicing none, and window-fit, a GNOME Shell extension that refits windows into the work area so a reconnect at a smaller resolution cannot strand one off-screen |
vm / optimizer |
vm |
VM guest optimisations (QEMU/SPICE agents) |
Deliberately outside soe: the right power answer differs per machine, and
imposing always-on on a laptop that lives in a bag cooks it.
Two different users matter on any machine reached over SSH, and conflating them is the most expensive mistake available here.
- The connecting user -- whoever Ansible authenticated as. On hyperi-infra's
fleet inventory that is the service account
ubuntu. - The desktop user -- whoever actually sits in front of the machine.
hyperi_target_usernames them;actual_useranduser_homederive from it, and every user-scoped task keys off those.
They are the same person on a laptop, which is why the default is right there and wrong on a fleet machine.
The playbook applies user-level settings for exactly ONE user per run.
There is no loop inside the roles, and that is deliberate: roughly 350
user-scoped references across 49 files means a per-task loop or a
user-scope tag would eventually miss one silently, which is the failure mode
this whole area already has a history of.
install.sh loops instead, once per user:
| Selection | Behaviour |
|---|---|
| default | every real login account, discovered from /etc/passwd |
--users a,b |
exactly those, in that order |
Discovery skips three kinds of account: root; system accounts (uid below 1000,
the systemd range above 60000, shells of nologin/false/sync); and the
cloud image's own account, which exists to provision the machine rather than
to work in.
That last one is read from system_info.default_user.name in
/etc/cloud/cloud.cfg rather than matched by name, because it is ubuntu on an
Ubuntu image and cloud-user on a Red Hat one. An override dropped in
cloud.cfg.d is not consulted -- use --users on a machine that does that.
--users is also how you deliberately include the cloud account.
No qualifying account means nothing is applied and the run stops, naming the
reason. A fresh cloud image holding only root, system accounts and its own is
exactly that case. There is no fallback to the invoking user on purpose --
falling back would write dotfiles into root or the provisioning account, which
is what the criteria exist to prevent.
Driving Ansible directly (Packer, hyperi-infra) means writing the loop yourself:
for u in ubuntu hyperi; do
ansible-playbook ... -e hyperi_target_user="$u"
doneThe system-wide tasks are idempotent, so the second and later passes no-op on everything except that user's own files, and no user's settings overwrite another's.
Getting it wrong reports success. A task that writes to a home directory as
the connecting user writes to the WRONG home and exits 0; the tools land where
nobody looks. ansible.builtin.file is worse -- it cannot read across into the
other home and reports "Insufficient permissions ... Treating as absent", a
green tombstone over a file that is still there.
So become: false is a defect on anything user-scoped, because it means the
CONNECTING user. The correct forms are:
| Situation | Form |
|---|---|
| Linux-only user-scoped task | become: true + become_user: "{{ actual_user }}" |
| Cross-platform user-scoped task | become: "{{ ansible_facts['distribution'] != 'MacOSX' }}" + become_user: "{{ actual_user }}" |
| macOS-only, or scoped to the invoker by definition | become: false |
The macOS split exists because Homebrew refuses to run as root, and macOS never
separates the two users anyway. become_user is IGNORED while become is
false, so become: false paired with become_user is always wrong.
The same trap sits in the molecule verify, which takes the desktop user from
MOLECULE_TARGET_DESKTOP_USER -- asserting against the service account's empty
home passes every user-scoped check on a host that was never fixed.
Every tool resolves its version when the installer runs. A box built today gets what is current today, and the same command in four months gets what is current then. No release number for anything with an upstream to ask lives in this repo, and there is no flag that changes it.
| Source | Behaviour |
|---|---|
| distro or vendor repo | state: present, and the repo carries the box forward on apt/dnf upgrade. |
| manual binary | The newest GitHub release at least hyperi_release_min_age_days (7) days old, then the matching asset. |
| cargo / go tools | cargo install X, go install X@latest. |
| go, rustup | The publisher's current release, verified against the checksum it serves beside it. |
Three things are deliberately not versions. Node's major (node_major) picks
an LTS line rather than a release, and NodeSource's signed repo patches it in
place. Rust's edition comes with whatever stable rustup installs, so edition
2024 needs no pin. kubectl's minor (infrastructure_kubectl_minor) picks the pkgs.k8s.io repository, which publishes one per Kubernetes minor and patches it in place. Left empty it follows the stable minor at https://dl.k8s.io/release/stable.txt. kubectl supports one minor either side of the API server, so this is the knob for cluster version skew. Set it to the minor of the clusters you work against, such as v1.36, and the next converge moves the repository and kubectl with it, up or down. A kubectl held with apt-mark hold or dnf versionlock add is left alone and the run warns.
GitHub releases wait 7 days. Every tool taken from a GitHub release installs the newest release at least hyperi_release_min_age_days old (default 7; set it in local-config/vars.yml or with -e), so a compromised or broken release has time to be pulled first. With none that old the installed copy stays and the run warns. Nothing newer is ever installed in its place. Releases from the hyperi-io org skip the wait (hyperi_release_cooldown_exempt).
go and rustup keep digest verification while tracking latest, by fetching
the checksum from the publisher at install time -- go.dev/dl/?mode=json carries
a SHA256 per file, and static.rust-lang.org serves rustup-init.sha256 beside
the binary.
Version resolution goes through api.github.com, which allows 60
requests an hour per IP anonymously. A run selecting infrastructure plus a
couple of languages spends a good fraction of that by itself, and every machine
sharing an egress address draws on the same 60 -- so a fleet rollout or an image
build ends up competing with itself. The run that fails is rarely the run that
spent the quota, and the failure surfaces as a 403 with the response headers
dumped rather than anything saying "rate limited".
Set GITHUB_TOKEN (or GH_TOKEN) in the environment of whoever runs the
playbook and every lookup sends it, lifting the ceiling to 5000/hour. Any
GitHub account's token works and nothing is stored in the repo:
GITHUB_TOKEN=$(gh auth token) ansible-playbook ...Without one the header is empty and every call stays anonymous, so a laptop install needs no token and behaves exactly as before.
Packaging ladder (pick the highest that works): distro repo (auto-updates) >
official vendor apt/dnf repo > official snap/flatpak > manual binary (last
resort, and the only rung that needs a SHA pin). The Method column above is the
resolved channel per tool - chosen to be the highest auto-updating rung available.
How current is current enough. A repo that is ALMOST up to date is fine -- a few patches, or a minor, behind upstream. A channel that leaves the tool weeks or months behind is not, whatever rung it sits on. Measure before promoting, and measure the actual package, not the repo's reputation.
A rung only counts if it actually moves. "Distro repo" is two different things, and the difference decides most of the table:
- Fedora updates packages within a release. kustomize matches upstream exactly, k9s matches, yq trails by a patch. Genuinely swept.
- Ubuntu LTS universe freezes at release for five years. lazygit 0.57.0 against upstream 0.64.1, gitleaks 8.16.0 against 8.30.1. That is a pin wearing a repo's clothes.
So for a fast-moving tool a Tier 3 binary that hyperi-update re-fetches beats
a universe package, and most distro promotions land on Fedora only. Check what
a package actually ships before promoting a tool onto it -- and check it is the
same tool at all. Three name collisions have already been ruled out: Ubuntu
tea is a GUI text editor, Ubuntu yq is kislyuk/yq, and Fedora act is
Autodesk's Automatic Component Toolkit.
Container format by distro: Ubuntu uses snap, Fedora uses flatpak. Neither
is used where a repo, brew or cask can keep the tool current instead. What
remains on a container format, and why nothing better exists: the aws-cli
snap on Ubuntu (universe's awscli is v2 but frozen) and the DBeaver flatpak on
Fedora (no vendor dnf repo). A strictly confined snap is ruled out for any tool
that reads a repo: it sees only $HOME, so a checkout under /projects is
invisible to it -- why actionlint and osv-scanner take the release binary. Ubuntu installs no flatpak app at all; the flatpak binary
stays only so the tombstones in removals.yml can clear ones an older revision
left behind.
A snap or distro package installs whatever its channel holds, which can trail upstream badly. That is why golangci-lint is a Tier 3 binary on Linux despite Fedora packaging it: the dnf build trails upstream by two minors, and a linter behind the Go toolchain cannot read the newer stdlib.
Moving a tool between channels leaves the old copy behind, and it wins.
/usr/local/bin and /snap/bin both precede /usr/bin on the default PATH, so
a binary superseded by a package shadows it forever and no update ever touches
it. Every channel move needs a tombstone for the path it vacated, and the
remediation molecule scenario asserts the replacement is what which resolves
to -- not merely that the new thing installed.
A hand install leaves a second copy too. The removals tag clears copies of the managed tools in ~/.local/bin and ~/go/bin where the managed copy exists and is a different file. A deliberate pin there (an older kubectl, golangci-lint v1) goes too, so pin per project instead. Tools whose name another program also uses (yq, tea, sd, act) are left alone. It also purges the upstream .deb or .rpm of macbash, git-scrub, golangci-lint or k9s installed beside the /usr/local/bin copy, unless a repository offers a package of that name.
Dropped tools leave on --tags removals. dive (no release since 2025-05) and gron (none since 2022) are no longer installed. A removals or soe run clears the /usr/local/bin copies older revisions put there and Ubuntu's gron package. A Mac keeps its brew formulae, as with every other retired CLI utility.
developer-go links go and gofmt into /usr/local/bin on every run, and on a removals or soe run also removes the distro Go once a working, self-contained /usr/local/go is in. developer-rust retires cargo-tarpaulin on every run, and on a removals or soe run clears cargo-home duplicates and, where the host exports a relocated CARGO_HOME, the old ~/.cargo/bin binaries the effective home also holds. A package something else depends on stays, and the run says which. Its direct dependencies that a purge would orphan are marked manually installed, so hyperi-update's autoremove does not take them later.
Every tool stays current; the mechanism depends on its channel. Three tiers:
Tier 1 - OS-swept. Tools from a distro package, an official vendor apt/dnf
repo, or an official snap are refreshed by the host's own updates:
unattended-upgrades / dnf-automatic on Linux, snapd auto-refresh, or
brew upgrade on macOS. The majority of tools. Nothing HyperI-specific runs.
Swept does not mean current: an Ubuntu universe package is refreshed only for security, so it holds the version it shipped with for the life of the release (see the ladder note above). A vendor repo, a snap and Fedora's own packages all track upstream properly.
Tier 2 - language-manager tools. Tools installed by uv / cargo / go / npm / pnpm have no OS channel, so hyperi-update refreshes them through each manager: uv tool upgrade --all, rustup update and cargo install-update -a --locked, go install ...@latest for the tools in ~/go/bin (only when the module or the Go toolchain moved), npm update -g, and pnpm update -g --latest. The pnpm globals (eslint, prettier, typescript, tsx) are installed unpinned, so --latest moves them to what a fresh converge would install, majors included. uv python upgrade moves each uv-managed Python to its newest patch without adding a python or python3 shim, and leaves the superseded patch installed, because uv has no command that removes only those. E.g. ruff, ty, semgrep, pip-audit, cargo-audit, cargo-hack, typos, govulncheck, maid.
Tier 3 - static binaries. A handful ship only as a release binary with no repo, snap, or language manager: kind, argocd, kubeconform, kube-linter, terraform-docs, golangci-lint, lazygit, actionlint, osv-scanner, aws-vault, git-scrub, sccache, fnm, hadolint, tea and macbash on both distros, k9s, kustomize, yq, gitleaks and act on Ubuntu, and sd, kubectx and kubens on Fedora. hyperi-update re-fetches each one only where the role put it in /usr/local/bin on that distro, so the binary cannot shadow a packaged copy. Each download must match the sha256 GitHub publishes for the asset, or the release's checksum file where there is no digest (tea and macbash publish a .sha256 beside the asset), and is renamed into place so the working copy is never half-written. Each GitHub release is the newest at least 7 days old, as in the roles, which write their age and exempt orgs to /etc/default/hyperi-update. HYPERI_RELEASE_MIN_AGE_DAYS in the environment overrides that file and --min-age DAYS overrides both. With no release old enough the installed copy stays and the summary lists it. A stamp in /var/lib/hyperi-update stops an unmoved release being downloaded again, and the GitHub API is asked with the last ETag, so with a token set an unchanged release costs no rate limit (an anonymous 304 still counts). When the API refuses, the release document from the last run stands in. On Ubuntu, uv and uvx in ~/.local/bin are refreshed the same way as the invoking user.
golangci-lint is here for a different reason: Fedora does package it, but the build trails upstream, and a linter behind the Go toolchain cannot read the newer stdlib.
Codex fits no tier cleanly. On macOS it is Tier 1: the codex cask, swept
by brew upgrade. On Linux there is no repo, no snap and no language manager,
and it is not Tier 3 either - the release asset is a package TREE that has to be
staged and symlinked, not a bare binary the re-fetch helpers could drop into
/usr/local/bin. Its update path is re-running the official installer, which
resolves the current release, verifies the payload digest and short-circuits
when what is staged is already current. hyperi-update does exactly that,
guarded on codex being present.
The Codex plugin is a channel of its own again: claude plugin update codex@openai-codex, which the package manager does not reach because it moves the CLI and not its marketplace plugins.
hyperi-update (the "update my system" command) runs all three tiers and the
two out-of-tier channels above, plus the OS / snap / flatpak sweep, so one
command brings everything current. soe schedules it on a timer; the base leaves
update cadence to the host.
tools/hyperi-doctor is the read-only counterpart to everything above. It
resolves the same role graph documented here, recursing through each role's
meta/main.yml dependencies, then reports which declared apt / dnf / homebrew
packages are missing from this host. It applies nothing and needs no sudo.
It also reads the applied-state stamp
(/var/lib/hyperi-developer/applied.json), written at the end of a run, and
compares its git SHA against the current checkout -- so "this host has not
been reprovisioned in a month" is answerable rather than something you find
out through a build failure in an unrelated dependency.
Package names built from a loop, a variable or Jinja cannot be resolved by a static scan of the task files, and it reports that count explicitly. A gate that quietly under-reports is worse than no gate.
The developer base aims to be a lightweight additive base that leaves your
existing tools and configuration alone. It does not remove tools you already
have or apply HyperI org policy - those moved to soe and to explicit opt-in
tags. This is a design goal, not a guarantee: consistent with the Apache-2.0
"AS IS, without warranties" stance, we make no assurances. The base is not
zero-footprint - it adds the Docker repo and group, a Git PPA on Ubuntu, and
Flathub when GUI apps are requested.
What lives off the default path (never, and soe for HyperI machines):
tombstones/removals, the branded update app, /usr/local/sbin admin scripts,
the Fedora podman purge (cut to the conflicting podman-docker only), and the
full dist-upgrade (gated behind hyperi_system_upgrade, default cache-clean).