feat: hu setup — universal jackknife (linuxbrew + mise + stow + 1Password SSH)#1
Merged
Conversation
added 15 commits
May 8, 2026 18:36
Adds `hu setup` top-level subcommand with 7 sub-actions stubbed: run, preview, status, pkgs, dotfiles, ssh, config. All return "not yet implemented" for now — wiring only. - src/setup/cli.rs: SetupCommand + ConfigCommand enums, RunArgs/PkgsArgs - src/setup/types.rs: Status enum (Already/Installed/Skipped/Failed/Unknown) + icon mapping - src/setup/mod.rs: dispatch entry point - src/cli.rs: Setup variant on top-level Command - src/main.rs: Setup dispatch + module declaration Phase 0 chunk 1/4 of feat/jackknife. Tests: 7 new, all green.
- src/util/shell.rs: trait Shell with run() + which(), RealShell wrapping
tokio::process, FakeShell (cfg test) with scripted (cmd,args)→output
responses and call recording. Single chokepoint for all process I/O
per project doctrine §1 — covers brew/mise/gh/op/stow without
per-binary trait wrappers.
- src/setup/os.rs: Os::detect() returning Os::Mac | Os::Linux{distro} |
Os::Other{name}. Distro parsed from /etc/os-release ID= line, lowercased,
quote-stripped. Falls back to "linux" when ID is missing.
Phase 0 chunk 2/4. Tests: 15 new (6 shell + 9 os), all 2227 unit tests
+ 25 integration tests green.
- src/setup/config.rs: SetupConfig with DotfilesConfig, SshConfig,
PackagesConfig, HostOverride. Defaults derived from PLAN.md
(aladac/dotfiles + stow, op CLI for SSH, brew T1 + mise T2 lists).
Pure serialize/deserialize functions tested via round-trip.
- Path resolution via existing `directories::ProjectDirs::from("","","hu")`
pattern: macOS ~/Library/Application Support/hu/setup.toml,
Linux ~/.config/hu/setup.toml.
- `hu setup config init` writes default if absent (idempotent).
- `hu setup config path` prints resolved path + existence indicator.
Per doctrine §1: serialize/deserialize unit-tested directly; std::fs::*
calls are I/O glue and run only at execution time.
Phase 0 chunk 3/4. Tests: 10 new config tests, total 2237 unit + 25 integration.
- src/setup/status.rs: collect() walks configured brew + mise packages and reports presence via Shell::which. binary_name() handles known mismatches (postgresql→psql) and strips mise version qualifiers (node@lts→node, rust@latest→rustc, python@*→python3). - src/setup/display.rs: render() produces a comfy_table with the project-standard UTF8_FULL_CONDENSED preset, color-coded ✓/◐/○/✗ icons, Note column. summary() prints "X/Y satisfied" line. - src/setup/mod.rs: wire `hu setup status` and `preview` to detect OS, load config (default if missing), call collect+render via RealShell. Smoke run on fuji: 16/19 satisfied (correct: stow, mise, postgresql not yet present locally; everything else green). Phase 0 chunk 4/4 — Phase 0 complete. Tests: 16 new (8 status + 8 display), total 2253 unit + 25 integration.
Phase 1 chunk 1/4. Earned trait abstraction (≥2 implementers — BrewInstaller now, MiseInstaller in Phase 2 — per project doctrine §1). - src/setup/packages.rs: trait Installer with check / install / ensure. ensure() encodes doctrine §9 idempotency contract: check → skip-or-install → re-verify, catching the "exit 0 ≠ side effect happened" failure mode. InstallResult struct carries Status + note for status-table rendering. - BrewInstaller: wraps `brew list --versions <pkg>` + `brew install <pkg>` via the existing Shell chokepoint. Static dispatch — methods take `&S: Shell + ?Sized`. - src/util/shell.rs: extend FakeShell with expect_sequence() — pop-front response queue per (cmd, args) key. Enables the full check→install→re-verify test path where second check observes post-install state. Tests: 11 new (10 packages + 1 sequence), total 2264 unit + 25 integration.
Phase 1 chunk 2/4. Makes the host capable of running BrewInstaller.
- src/setup/bootstrap.rs:
- ensure_brew(): checks `which brew`, installs via official upstream
script (NONINTERACTIVE=1 + curl install.sh) if missing, re-verifies.
- ensure_linuxbrew_prereqs(): walks LINUXBREW_APT_PREREQS
(build-essential, curl, git, procps, file) — skips on non-Linux,
installs each via dpkg-s check + sudo apt-get install -y on Linux.
- All paths through Shell chokepoint (no per-binary trait wrappers).
- Doctrine §9 idempotency: every step check → skip-or-act → re-verify.
Tests: 8 new (4 brew bootstrap paths + 4 linuxbrew prereq paths).
Total 2271 unit + 25 integration green.
Phase 1 chunk 3/4. `hu setup pkgs` now: 1. Detects OS, loads setup.toml (or default) 2. Ensures linuxbrew apt prereqs (skipped on macOS) 3. Bootstraps brew itself if missing (T0) 4. Walks filtered brew package list, ensure() per package (T1) 5. Renders comfy_table status, exits non-zero on any Failed row - src/setup/pkgs.rs: orchestrator service. filter_brew_packages() applies --only flag (silently drops names not in config). dry_run_rows() returns Skipped(dry-run) without touching the shell. run() delegates to bootstrap::ensure_* + BrewInstaller::ensure. - src/setup/mod.rs: run_pkgs() dispatcher — RealShell + status-table render. Honors --dry-run banner. Bails on any failed row. - src/setup/bootstrap.rs: BREW_INSTALL is now pub(crate) for service-layer use. Smoke: `hu setup pkgs --only gh,jq --dry-run` on fuji prints the planned 3-row table (bootstrap brew + gh + jq) without touching shell. Tests: 8 new (filter unit tests + dry-run paths + bootstrap-fails-skips-T1 + happy-path multi-package walk + only-filter narrows). Total 2279 unit + 25 integration green.
Phase 2 chunk 1/2. Second concrete `Installer` impl validates the trait
abstraction (doctrine §1: trait earned when ≥2 implementers).
- src/setup/packages.rs:
- split_lang_version() — parses `lang@version` (defaults to "latest"
when no `@` is present)
- MiseInstaller — check via `mise current <lang>` (exit 0 + non-empty
stdout = active), install via `mise use -g <pkg@version>`
- check accepts any active version as satisfying the request — version
upgrades are out of scope (handled separately via `mise upgrade`)
Both installers reuse the same trait-level `ensure()` default impl, so
the doctrine §9 idempotency contract (check → install → re-verify) lands
identically for brew + mise.
Tests: 11 new (2 split_lang_version + 9 MiseInstaller paths).
Total 2290 unit + 25 integration green.
Phase 2 chunk 2/2. `hu setup pkgs` now walks T2 mise packages after T1
brew is satisfied.
- src/setup/pkgs.rs:
- Renamed `filter_brew_packages` → `filter_packages` (handles brew + mise)
- run() loops mise list after brew. Gates on `which mise` — if mise isn't
on PATH (common when brew just installed it), surfaces each mise pkg
as Failed with a "re-run after `eval $(mise activate)`" hint instead
of attempting the install.
- dry_run_rows() now emits both brew and mise rows.
Smoke `hu setup pkgs --dry-run` on fuji prints the full 20-row plan:
1 bootstrap + 15 brew + 4 mise. Matches PLAN.md tier breakdown.
Tests: 3 new (brew→mise happy path, mise-not-on-path Failed surfacing,
dry-run includes mise rows). Total 2293 unit + 25 integration green.
Phase 3 chunk 1/2. Dotfiles flow primitives without dispatcher wiring.
- src/setup/dotfiles.rs:
- expand_tilde() — pure-function ~/ → $HOME expansion (avoids shellexpand
dep). Falls through unchanged for non-tilde paths.
- is_git_repo() — true iff <dir>/.git exists. Used to detect already-cloned
repos for skip-or-clone idempotency.
- ensure_clone() — gh repo clone <repo> <dest> with skip-on-existing,
re-verify (.git/ landed), and structured StatusRow output.
- stow_apply() — stow -R -d <clone> -t <target> <pkg>. Restow is
idempotent. Failure surfaces parse_stow_conflicts() summary.
- parse_stow_conflicts() — extracts first conflict path + count from
stow stderr ("existing target", "CONFLICT", "would cause conflicts"
keywords). Caps at one-line summary for table cells.
No dispatcher wiring yet — `hu setup dotfiles` still bails with
"not yet implemented (Phase 3)". Chunk 3.2 wires the orchestrator.
Tests: 11 new (tilde expansion, git-repo detection, clone-skip-on-existing,
clone-fail-on-gh-error, stow happy path, stow failure, conflict summarizer
edge cases). Total 2303 unit + 25 integration green.
Phase 3 chunk 2/2. Dotfiles dispatcher live.
- src/setup/dotfiles.rs:
- run() orchestrates ensure_clone → stow_apply per configured package.
Skips per-package stow rows with Skipped(clone failed) when the clone
step itself fails.
- src/setup/mod.rs:
- run_dotfiles() detects OS, loads config, prints repo→clone_to header,
walks rows, exits non-zero on any Failed.
Smoke `hu setup dotfiles` on fuji correctly:
1. ✓ Detects existing /Users/chi/Projects/dotfiles clone (Already)
2. ✗ Each stow package fails with "No such file or directory" (stow not
yet installed locally — ran `hu setup pkgs --only stow` would land it)
Tilde expansion verified: `~/Projects/dotfiles` resolves to absolute
path via $HOME without external dep.
Tests: 2 new (run_skips_stow_when_clone_fails, run_stows_each_package
when clone is present). Total 2305 unit + 25 integration green.
Phase 4 chunk 1/2. Second earned trait (per doctrine §1: ≥2 implementers
+ painful I/O — vault auth, account state).
- src/setup/ssh.rs:
- trait OpClient: read(op_ref) + account_status()
- RealOp wraps `op read` and `op account list` via Shell chokepoint.
Generic over <S: Shell + ?Sized> for static dispatch.
- MockOp (cfg test): scripted op_ref → content map, signed-in toggle,
read call recording. Errors on unscripted reads.
- KeySpec — pure description (path/mode/content) of one file to write.
- key_specs_for_item() — extracts basename from "SSH/id_ed25519"-style
item refs, builds private (0600) + public (0644) specs with normalized
trailing newlines.
- op_ref() — formats op://vault/item/field references.
- fetch_key_pair() — reads both private_key + public_key fields for an
item, propagates context on failure.
No dispatcher wiring yet — `hu setup ssh` still bails with
"not yet implemented (Phase 4)". Chunk 4.2 wires the orchestrator + fs
glue.
Tests: 13 new (op_ref formatting, newline normalization, key spec basename
extraction, key spec modes, MockOp scripted reads + signed-in state,
fetch_key_pair both-fields + error propagation, RealOp shell wiring +
account-status). Total 2318 unit + 25 integration green.
Phase 4 chunk 2/2. SSH dispatcher live.
- src/setup/ssh.rs:
- SpecAction enum + classify_spec() — pure decision: write vs skip
based on file existence, content equality, and mode equality.
- apply_spec() — fs::write + chmod glue with re-verify on the final
mode. Idempotent: classify_spec returns AlreadyMatches → no-op.
- run() orchestrator: account_status() precheck → for each op_item,
fetch_key_pair → key_specs_for_item → apply_spec per spec.
Surfaces "no signed-in 1Password account → run `op account add`"
as a clear failure rather than letting per-item op reads error
cryptically.
- src/setup/mod.rs:
- run_ssh() detects OS, loads config, instantiates RealOp over
RealShell, walks rows, exits non-zero on any Failed.
Tests: 9 new (classify_spec matrix: missing/match/content-diff/mode-diff,
apply_spec write+verify, apply_spec already-match skip, run not-signed-in,
run happy path with both keys + correct perms 0600/0644 verified on disk,
run op-fetch-fail surfaced per item). Total 2327 unit + 25 integration green.
Phase 5 — keystone chunk. `hu setup run` ties Phases 1-4 together.
- src/setup/run.rs:
- current_hostname() — env $HOSTNAME → `hostname` shellout fallback
- apply_host_overrides() — merges [host.<name>].brew_extra and
mise_extra into base config without duplicates
- run_full() — walks pkgs → dotfiles → ssh in order. Honors --only
filter (single phase) and --dry-run (returns Skipped(dry-run) rows
without touching shell/op).
- src/setup/mod.rs:
- run_full() dispatcher reads config, applies host overrides for the
detected hostname, instantiates RealShell + RealOp, walks rows, exits
non-zero on any Failed.
Smoke `hu setup run --dry-run` on fuji prints the full 26-row plan:
- 1 bootstrap brew
- 15 brew packages
- 4 mise runtimes
- 5 stow dotfile packages
- 1 ssh key item
Tests: 9 new (host override append + dedupe + miss + mise variant,
should_run_phase filtering matrix, full dry-run emits all phases skipped,
--only Pkgs filters out stow/ssh, --only Ssh filters out brew/stow).
Total 2336 unit + 25 integration green.
Phase 6. Wired into `hu setup run` (only when running everything, not
under --only or --dry-run).
- src/setup/post.rs:
- start_postgres() — check `brew services info postgresql --json` for
running:true; only attempt `brew services start` if not running.
Idempotent.
- check_gh_auth() — wraps `gh auth status` (read-only). Surfaces
"run `gh auth login`" hint on failure.
- smoke_github_ssh() — `ssh -T git@github.com`. SSH exits 1 with greeting
on success (documented quirk); we grep "successfully authenticated"
in combined stdout+stderr. BatchMode=yes prevents password prompts;
StrictHostKeyChecking=accept-new TOFU-accepts the github fingerprint
on first run without prompting.
- Postgres step skipped when not in [packages].brew config.
- src/setup/run.rs: post::run() called at end of run_full() only when
args.only is None and !dry_run.
Tests: 6 new (postgres skipped when not configured, postgres started when
not running, postgres already-when-running, gh-auth-failed signal,
github-ssh greeting recognized, github-ssh failure when no greeting).
Total 2342 unit + 25 integration green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New top-level
hu setupsubcommand: fresh-host bootstrap for macOS and Linux.Single command —
hu setup run— converges a clean host to a fullyconfigured environment via linuxbrew + mise + stow + 1Password SSH.
aladac/dotfilesvia gh + apply viastow -R~/.sshwith chmod 0600/0644gh auth status,ssh -T git@github.comsmokeArchitecture (per project doctrine)
Shellchokepoint trait — one trait covers all process I/O (brew, mise, gh, op, stow, dpkg, apt, ssh, bash).RealShell+FakeShell(cfg test) with sequence-aware response queue forcheck → install → re-verifytest coverage.Installertrait — earned (≥2 implementers):BrewInstaller+MiseInstaller. Defaultensure()impl encodes doctrine §9 idempotency contract (check → skip-or-install → re-verify).OpClienttrait — earned (painful I/O + ≥2 impls):RealOp+MockOp.&impl Traiteverywhere; nodynoverhead.#![allow(dead_code)]with reason comments while features land per phase.CLI surface
Config at
~/.config/hu/setup.toml(Linux) /~/Library/Application Support/hu/setup.toml(macOS).Per-host overrides via
[host.<hostname>]blocks.Phases (12 commits, 14 chunks)
Test plan
just checkclean (cargo fmt + clippy -D warnings)cargo test --bin hu— 2342 unit + 25 integration greenhu setup statuson fuji — 16/19 satisfied (correctly flags missing mise/stow/postgresql locally)hu setup run --dry-runon fuji — 26-row plan rendered (1 bootstrap + 15 brew + 4 mise + 5 stow + 1 ssh)hu setup pkgs --only gh,jq --dry-run— filter narrows to 3 rowshu setup config path/inithu setup dotfileson fuji — clone detected, stow flagged missingssh marauder→ install hu →hu setup run --yes→ 100% green (deferred to Pilot)hu setup runreports all-Already (deferred to Pilot)Files changed
15 files, ~3000 insertions, 0 deletions.
All modules well under the 400-line file budget.
Calibration
🤖 Generated with Claude Code