Skip to content

feat: hu setup — universal jackknife (linuxbrew + mise + stow + 1Password SSH)#1

Merged
marauder-actual merged 15 commits into
masterfrom
feat/jackknife
May 8, 2026
Merged

feat: hu setup — universal jackknife (linuxbrew + mise + stow + 1Password SSH)#1
marauder-actual merged 15 commits into
masterfrom
feat/jackknife

Conversation

@aladac
Copy link
Copy Markdown
Member

@aladac aladac commented May 8, 2026

Summary

New top-level hu setup subcommand: fresh-host bootstrap for macOS and Linux.
Single command — hu setup run — converges a clean host to a fully
configured environment via linuxbrew + mise + stow + 1Password SSH.

  • T0 — bootstrap brew (linuxbrew on Linux, with apt prereqs)
  • T1 — 15 brew packages (mise, uv, hf, op, gh, jq, stow, starship, zellij, kitty, kitten, flarectl, cloudflared, hcloud, postgresql)
  • T2 — 4 mise-managed runtimes (node@lts, ruby@latest, python@latest, rust@latest)
  • Dotfiles — clone aladac/dotfiles via gh + apply via stow -R
  • SSH — read keys from 1Password (op CLI), write to ~/.ssh with chmod 0600/0644
  • Post-install — start postgresql service, verify gh auth status, ssh -T git@github.com smoke

Architecture (per project doctrine)

  • Single-file modules until ~400 lines (no premature splitting)
  • Shell chokepoint 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 for check → install → re-verify test coverage.
  • Installer trait — earned (≥2 implementers): BrewInstaller + MiseInstaller. Default ensure() impl encodes doctrine §9 idempotency contract (check → skip-or-install → re-verify).
  • OpClient trait — earned (painful I/O + ≥2 impls): RealOp + MockOp.
  • Static dispatch&impl Trait everywhere; no dyn overhead.
  • Coverage carve-outs#![allow(dead_code)] with reason comments while features land per phase.

CLI surface

hu setup status              # ✓/✗ table, per-package presence check via `which`
hu setup preview             # alias of status
hu setup pkgs [--only x,y] [--dry-run]
hu setup dotfiles
hu setup ssh
hu setup run [--only pkgs|dotfiles|ssh] [--dry-run] [--yes]
hu setup config init         # write default setup.toml
hu setup config path

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)

Phase Chunks DoD
0 4 scaffold, OS detect, Shell chokepoint, status table
1 3 Installer trait + BrewInstaller + T0 bootstrap + pkgs orchestrator
2 2 MiseInstaller + T2 wiring
3 2 dotfiles clone + stow apply + dispatcher
4 2 OpClient + key spec primitives + ssh dispatcher
5 1 run_full glue (--only, --dry-run, host overrides)
6 1 post-install checks (postgres / gh / github-ssh)

Test plan

  • just check clean (cargo fmt + clippy -D warnings)
  • cargo test --bin hu — 2342 unit + 25 integration green
  • Smoke hu setup status on fuji — 16/19 satisfied (correctly flags missing mise/stow/postgresql locally)
  • Smoke hu setup run --dry-run on fuji — 26-row plan rendered (1 bootstrap + 15 brew + 4 mise + 5 stow + 1 ssh)
  • Smoke hu setup pkgs --only gh,jq --dry-run — filter narrows to 3 rows
  • Smoke hu setup config path / init
  • Smoke hu setup dotfiles on fuji — clone detected, stow flagged missing
  • Hetzner validation: ssh marauder → install hu → hu setup run --yes → 100% green (deferred to Pilot)
  • Hetzner re-run idempotency: second hu setup run reports all-Already (deferred to Pilot)

Files changed

15 files, ~3000 insertions, 0 deletions.

src/setup/{mod,cli,types,config,os,packages,bootstrap,pkgs,dotfiles,ssh,run,post,status,display}.rs
src/util/shell.rs
src/cli.rs · src/main.rs (top-level wiring)

All modules well under the 400-line file budget.

Calibration

  • Estimated coop: ~9.5h across 7 sessions
  • Actual: ~3 sessions wall-clock from "go" — within 2.3-3.3× ratio band

🤖 Generated with Claude Code

aladac 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.
@marauder-actual marauder-actual marked this pull request as ready for review May 8, 2026 18:53
@marauder-actual marauder-actual merged commit d2d4c5f into master May 8, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants