From 1124fae37cf016c37ea634075e20a3c15f8e5405 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 18:36:08 +0200 Subject: [PATCH 01/15] feat(setup): scaffold module skeleton + CLI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/cli.rs | 7 +++ src/main.rs | 7 +++ src/setup/cli.rs | 137 +++++++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 37 ++++++++++++ src/setup/types.rs | 61 ++++++++++++++++++++ 5 files changed, 249 insertions(+) create mode 100644 src/setup/cli.rs create mode 100644 src/setup/mod.rs create mode 100644 src/setup/types.rs diff --git a/src/cli.rs b/src/cli.rs index 6060b37..a0b1b41 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,6 +14,7 @@ use crate::pagerduty::PagerDutyCommand; use crate::pipeline::PipelineCommand; use crate::read::ReadArgs; use crate::sentry::SentryCommand; +use crate::setup::SetupCommand; use crate::shell::ShellCommand; use crate::slack::SlackCommands; use crate::utils::UtilsCommand; @@ -129,4 +130,10 @@ pub enum Command { #[command(subcommand)] cmd: Option, }, + + /// Universal fresh-host bootstrap (packages, dotfiles, ssh) + Setup { + #[command(subcommand)] + cmd: Option, + }, } diff --git a/src/main.rs b/src/main.rs index 795ef4a..56ab888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod pagerduty; mod pipeline; mod read; mod sentry; +mod setup; mod shell; mod slack; mod util; @@ -138,6 +139,12 @@ async fn run_command(cmd: Command) -> anyhow::Result<()> { Command::Mcp { cmd: None } => { print_subcommand_help("mcp")?; } + Command::Setup { cmd: Some(cmd) } => { + return setup::run_command(cmd).await; + } + Command::Setup { cmd: None } => { + print_subcommand_help("setup")?; + } } Ok(()) } diff --git a/src/setup/cli.rs b/src/setup/cli.rs new file mode 100644 index 0000000..0ed4464 --- /dev/null +++ b/src/setup/cli.rs @@ -0,0 +1,137 @@ +//! CLI argument types for `hu setup`. + +use clap::{Args, Subcommand}; + +#[derive(Subcommand, Debug)] +pub enum SetupCommand { + /// Run full setup: packages, dotfiles, ssh + Run(RunArgs), + + /// Show what would be installed without making changes (alias of status) + Preview, + + /// Show package and config status with ✓/✗ icons + Status, + + /// Install/refresh packages only + Pkgs(PkgsArgs), + + /// Clone and apply dotfiles only + Dotfiles, + + /// Sync SSH keys from 1Password only + Ssh, + + /// Manage the setup config file + Config { + #[command(subcommand)] + cmd: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommand { + /// Write a default setup.toml to the config dir + Init, + + /// Print the resolved config path + Path, +} + +#[derive(Args, Debug, Default)] +pub struct RunArgs { + /// Restrict run to a single phase + #[arg(long, value_enum)] + pub only: Option, + + /// Print intended actions without executing + #[arg(long)] + pub dry_run: bool, + + /// Suppress interactive confirmations + #[arg(short, long)] + pub yes: bool, +} + +#[derive(Args, Debug, Default)] +pub struct PkgsArgs { + /// Comma-separated package names to install (default: all configured) + #[arg(long, value_delimiter = ',')] + pub only: Vec, + + /// Print intended actions without executing + #[arg(long)] + pub dry_run: bool, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum RunPhase { + Pkgs, + Dotfiles, + Ssh, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct TestCli { + #[command(subcommand)] + cmd: SetupCommand, + } + + #[test] + fn parses_status() { + let cli = TestCli::try_parse_from(["test", "status"]).unwrap(); + assert!(matches!(cli.cmd, SetupCommand::Status)); + } + + #[test] + fn parses_run_with_dry_run() { + let cli = TestCli::try_parse_from(["test", "run", "--dry-run"]).unwrap(); + match cli.cmd { + SetupCommand::Run(args) => { + assert!(args.dry_run); + assert!(!args.yes); + assert!(args.only.is_none()); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn parses_run_with_only_filter() { + let cli = TestCli::try_parse_from(["test", "run", "--only", "ssh", "--yes"]).unwrap(); + match cli.cmd { + SetupCommand::Run(args) => { + assert_eq!(args.only, Some(RunPhase::Ssh)); + assert!(args.yes); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn parses_pkgs_with_only_csv() { + let cli = TestCli::try_parse_from(["test", "pkgs", "--only", "gh,jq,op"]).unwrap(); + match cli.cmd { + SetupCommand::Pkgs(args) => { + assert_eq!(args.only, vec!["gh", "jq", "op"]); + } + _ => panic!("expected Pkgs"), + } + } + + #[test] + fn parses_config_init() { + let cli = TestCli::try_parse_from(["test", "config", "init"]).unwrap(); + assert!(matches!( + cli.cmd, + SetupCommand::Config { + cmd: Some(ConfigCommand::Init) + } + )); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 0000000..cb55e3a --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,37 @@ +//! `hu setup` — universal fresh-host bootstrap. +//! +//! Runs on a clean macOS or Linux host and converges the system to the +//! configured desired state: packages, dotfiles, SSH keys. +//! +//! Each step follows the idempotency contract `check → skip-or-act → re-verify`. + +mod cli; +mod types; + +pub use cli::SetupCommand; + +use anyhow::{bail, Result}; + +/// Dispatch entry point — called from `main.rs`. +pub async fn run_command(cmd: SetupCommand) -> Result<()> { + match cmd { + SetupCommand::Status | SetupCommand::Preview => { + bail!("hu setup status: not yet implemented (Phase 0 chunk 0.4)"); + } + SetupCommand::Run(_) => { + bail!("hu setup run: not yet implemented (Phase 5)"); + } + SetupCommand::Pkgs(_) => { + bail!("hu setup pkgs: not yet implemented (Phase 1)"); + } + SetupCommand::Dotfiles => { + bail!("hu setup dotfiles: not yet implemented (Phase 3)"); + } + SetupCommand::Ssh => { + bail!("hu setup ssh: not yet implemented (Phase 4)"); + } + SetupCommand::Config { cmd: _ } => { + bail!("hu setup config: not yet implemented (Phase 0 chunk 0.3)"); + } + } +} diff --git a/src/setup/types.rs b/src/setup/types.rs new file mode 100644 index 0000000..4206039 --- /dev/null +++ b/src/setup/types.rs @@ -0,0 +1,61 @@ +//! Shared types for the setup module. + +// reason: Status + impls land in Phase 0 chunk 0.4 (status table) and Phase 1+ (installers). +// Tests cover them now; suppress dead_code until first runtime caller wires up. +#![allow(dead_code)] + +/// Status of a package, dotfile, or SSH artifact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Already in desired state — no action taken + Already, + /// Action performed and re-verified + Installed, + /// Action skipped (filtered out, dry-run, conflicts) + Skipped, + /// Failed to reach desired state + Failed, + /// Not yet checked + Unknown, +} + +impl Status { + /// Single-character icon for status tables. + pub fn icon(self) -> &'static str { + match self { + Status::Already => "✓", + Status::Installed => "✓", + Status::Skipped => "◐", + Status::Failed => "✗", + Status::Unknown => "○", + } + } + + /// Whether the status represents a desired-state match. + pub fn is_satisfied(self) -> bool { + matches!(self, Status::Already | Status::Installed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn icons_match_doctrine() { + assert_eq!(Status::Already.icon(), "✓"); + assert_eq!(Status::Installed.icon(), "✓"); + assert_eq!(Status::Skipped.icon(), "◐"); + assert_eq!(Status::Failed.icon(), "✗"); + assert_eq!(Status::Unknown.icon(), "○"); + } + + #[test] + fn is_satisfied_only_for_present_states() { + assert!(Status::Already.is_satisfied()); + assert!(Status::Installed.is_satisfied()); + assert!(!Status::Skipped.is_satisfied()); + assert!(!Status::Failed.is_satisfied()); + assert!(!Status::Unknown.is_satisfied()); + } +} From 3958c39a9dc98797b30af2a2ec33e1512675a0db Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 18:39:13 +0200 Subject: [PATCH 02/15] feat(setup): add Shell chokepoint + Os detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/mod.rs | 1 + src/setup/os.rs | 172 +++++++++++++++++++++++++++++++++++++++ src/util/mod.rs | 1 + src/util/shell.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 src/setup/os.rs create mode 100644 src/util/shell.rs diff --git a/src/setup/mod.rs b/src/setup/mod.rs index cb55e3a..2463a33 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -6,6 +6,7 @@ //! Each step follows the idempotency contract `check → skip-or-act → re-verify`. mod cli; +mod os; mod types; pub use cli::SetupCommand; diff --git a/src/setup/os.rs b/src/setup/os.rs new file mode 100644 index 0000000..00a0590 --- /dev/null +++ b/src/setup/os.rs @@ -0,0 +1,172 @@ +//! Operating system detection for the setup module. +//! +//! Detects macOS via `cfg!(target_os)` and Linux distro via parsing +//! `/etc/os-release`. Distro string is lowercased for stable matching. + +// reason: items used by Phase 0 chunk 0.4 (status table) and Phase 1+ (per-OS install paths). +// Tests cover them now; suppress dead_code until the first runtime caller wires in. +#![allow(dead_code)] + +use std::path::Path; + +use anyhow::Result; + +/// Detected host operating system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Os { + Mac, + Linux { distro: String }, + Other { name: String }, +} + +impl Os { + /// Detect the current host's OS. + /// + /// On macOS this is a constant. On Linux it reads `/etc/os-release` and + /// extracts the `ID=` field. Falls back to `Os::Other` for unknown + /// platforms. + pub fn detect() -> Result { + if cfg!(target_os = "macos") { + return Ok(Os::Mac); + } + if cfg!(target_os = "linux") { + let raw = std::fs::read_to_string("/etc/os-release") + .unwrap_or_else(|_| String::from("ID=linux")); + return Ok(Os::Linux { + distro: parse_os_release_id(&raw), + }); + } + Ok(Os::Other { + name: std::env::consts::OS.to_string(), + }) + } + + /// Short label for status tables. + pub fn label(&self) -> String { + match self { + Os::Mac => "macOS".to_string(), + Os::Linux { distro } => format!("linux ({})", distro), + Os::Other { name } => name.clone(), + } + } + + /// True when the host is macOS. + pub fn is_macos(&self) -> bool { + matches!(self, Os::Mac) + } + + /// True when the host is any Linux distro. + pub fn is_linux(&self) -> bool { + matches!(self, Os::Linux { .. }) + } +} + +/// Parse the `ID=` value out of an `/etc/os-release` blob. +/// +/// Strips surrounding quotes and lowercases. Returns `"linux"` as a safe +/// fallback when no `ID=` line is present. +pub fn parse_os_release_id(raw: &str) -> String { + raw.lines() + .find_map(|line| line.strip_prefix("ID=")) + .map(strip_quotes) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "linux".to_string()) +} + +fn strip_quotes(s: &str) -> String { + let trimmed = s.trim(); + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + trimmed[1..trimmed.len() - 1].to_string() + } else { + trimmed.to_string() + } +} + +/// Whether `/etc/os-release` exists on disk (used by integration tests). +pub fn has_os_release_file() -> bool { + Path::new("/etc/os-release").exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ubuntu_id() { + let raw = r#"NAME="Ubuntu" +ID=ubuntu +VERSION_ID="24.04" +"#; + assert_eq!(parse_os_release_id(raw), "ubuntu"); + } + + #[test] + fn parses_quoted_id() { + let raw = "ID=\"debian\"\n"; + assert_eq!(parse_os_release_id(raw), "debian"); + } + + #[test] + fn parses_single_quoted_id() { + let raw = "ID='arch'\n"; + assert_eq!(parse_os_release_id(raw), "arch"); + } + + #[test] + fn lowercases_id() { + let raw = "ID=Fedora\n"; + assert_eq!(parse_os_release_id(raw), "fedora"); + } + + #[test] + fn falls_back_when_no_id() { + let raw = "NAME=Foo\nVERSION=1.0\n"; + assert_eq!(parse_os_release_id(raw), "linux"); + } + + #[test] + fn empty_input_falls_back() { + assert_eq!(parse_os_release_id(""), "linux"); + } + + #[test] + fn label_for_macos() { + assert_eq!(Os::Mac.label(), "macOS"); + } + + #[test] + fn label_for_linux_includes_distro() { + let os = Os::Linux { + distro: "ubuntu".into(), + }; + assert_eq!(os.label(), "linux (ubuntu)"); + } + + #[test] + fn label_for_other() { + let os = Os::Other { + name: "freebsd".into(), + }; + assert_eq!(os.label(), "freebsd"); + } + + #[test] + fn predicates_match_variant() { + assert!(Os::Mac.is_macos()); + assert!(!Os::Mac.is_linux()); + let linux = Os::Linux { + distro: "ubuntu".into(), + }; + assert!(linux.is_linux()); + assert!(!linux.is_macos()); + } + + #[test] + fn detect_returns_a_known_variant() { + let os = Os::detect().expect("detect should not fail on supported hosts"); + // We don't assert the specific variant — runs across mac + linux CI. + assert!(os.is_macos() || os.is_linux() || matches!(os, Os::Other { .. })); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 9577fbc..6fd5fcf 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,5 +1,6 @@ mod config; mod output; +pub mod shell; pub use config::{ load_credentials, save_credentials, BraveCredentials, GithubCredentials, JiraCredentials, diff --git a/src/util/shell.rs b/src/util/shell.rs new file mode 100644 index 0000000..99ebc6e --- /dev/null +++ b/src/util/shell.rs @@ -0,0 +1,201 @@ +//! Single chokepoint for external process I/O. +//! +//! Every shellout in `hu setup` (brew, mise, gh, op, stow, etc.) routes through +//! the [`Shell`] trait. The real impl uses `tokio::process::Command`; tests use +//! [`FakeShell`] which scripts (cmd, args) → output mappings. +//! +//! Per project doctrine (CLAUDE.md §1): one trait covers all process I/O so +//! every caller is testable without wrapping each binary in its own trait. + +// reason: trait + RealShell wired by Phase 0 chunk 0.4 (status checker via `which`) +// and Phase 1+ installers. FakeShell exists only under cfg(test). Suppress +// dead_code until the first runtime caller lands. +#![allow(dead_code)] + +use std::process::ExitStatus; + +use anyhow::Result; +use async_trait::async_trait; + +/// Result of running an external command. +#[derive(Debug, Clone)] +pub struct ShellOutput { + pub status: ExitStatus, + pub stdout: String, + pub stderr: String, +} + +impl ShellOutput { + pub fn is_success(&self) -> bool { + self.status.success() + } +} + +/// Process I/O chokepoint. +#[async_trait] +pub trait Shell: Send + Sync { + /// Run a command with arguments and return captured output. + async fn run(&self, cmd: &str, args: &[&str]) -> Result; + + /// Convenience: returns true when the command exists on PATH. + async fn which(&self, cmd: &str) -> bool { + match self.run("which", &[cmd]).await { + Ok(out) => out.is_success(), + Err(_) => false, + } + } +} + +/// Real implementation backed by `tokio::process::Command`. +pub struct RealShell; + +#[async_trait] +impl Shell for RealShell { + async fn run(&self, cmd: &str, args: &[&str]) -> Result { + let output = tokio::process::Command::new(cmd) + .args(args) + .output() + .await?; + Ok(ShellOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +#[cfg(test)] +mod fake { + use super::*; + use std::collections::HashMap; + use std::os::unix::process::ExitStatusExt; + use std::sync::Mutex; + + /// Scripted shell for tests. Maps `(cmd, args)` → canned output. + /// + /// Calls without a registered response default to exit code 127 (command + /// not found) which mirrors real shell behaviour. + pub struct FakeShell { + responses: Mutex>, + calls: Mutex)>>, + } + + impl FakeShell { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + calls: Mutex::new(Vec::new()), + } + } + + /// Register a response for a `(cmd, args)` invocation. + pub fn expect(&self, cmd: &str, args: &[&str], stdout: &str, exit_code: i32) { + let key = Self::key(cmd, args); + let status = ExitStatus::from_raw(exit_code << 8); + self.responses.lock().expect("fake shell mutex").insert( + key, + ShellOutput { + status, + stdout: stdout.to_string(), + stderr: String::new(), + }, + ); + } + + /// Recorded call log. + pub fn calls(&self) -> Vec<(String, Vec)> { + self.calls.lock().expect("fake shell mutex").clone() + } + + fn key(cmd: &str, args: &[&str]) -> String { + format!("{} {}", cmd, args.join(" ")) + } + } + + #[async_trait] + impl Shell for FakeShell { + async fn run(&self, cmd: &str, args: &[&str]) -> Result { + let key = Self::key(cmd, args); + self.calls.lock().expect("fake shell mutex").push(( + cmd.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + let map = self.responses.lock().expect("fake shell mutex"); + match map.get(&key) { + Some(out) => Ok(out.clone()), + None => Ok(ShellOutput { + status: ExitStatus::from_raw(127 << 8), + stdout: String::new(), + stderr: format!("FakeShell: unscripted call: {}", key), + }), + } + } + } +} + +#[cfg(test)] +pub use fake::FakeShell; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fake_shell_returns_scripted_output() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "gh"], "gh\n", 0); + let out = shell.run("brew", &["list", "gh"]).await.unwrap(); + assert!(out.is_success()); + assert_eq!(out.stdout, "gh\n"); + } + + #[tokio::test] + async fn fake_shell_records_calls() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "jq"], "", 0); + let _ = shell.run("brew", &["list", "jq"]).await.unwrap(); + let calls = shell.calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "brew"); + assert_eq!(calls[0].1, vec!["list", "jq"]); + } + + #[tokio::test] + async fn fake_shell_unscripted_returns_127() { + let shell = FakeShell::new(); + let out = shell.run("nonexistent", &[]).await.unwrap(); + assert!(!out.is_success()); + assert_eq!(out.status.code(), Some(127)); + assert!(out.stderr.contains("unscripted call")); + } + + #[tokio::test] + async fn which_returns_true_when_command_exits_zero() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + assert!(shell.which("brew").await); + } + + #[tokio::test] + async fn which_returns_false_when_command_exits_nonzero() { + let shell = FakeShell::new(); + shell.expect("which", &["nonexistent"], "", 1); + assert!(!shell.which("nonexistent").await); + } + + #[tokio::test] + async fn shell_output_is_success_matches_status() { + let ok = ShellOutput { + status: std::os::unix::process::ExitStatusExt::from_raw(0), + stdout: String::new(), + stderr: String::new(), + }; + assert!(ok.is_success()); + let bad = ShellOutput { + status: std::os::unix::process::ExitStatusExt::from_raw(1 << 8), + stdout: String::new(), + stderr: String::new(), + }; + assert!(!bad.is_success()); + } +} From a5450816054694585f92739c137ee5cf92654749 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 18:42:00 +0200 Subject: [PATCH 03/15] feat(setup): add SetupConfig + TOML loader + config init/path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/config.rs | 294 ++++++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 56 ++++++++- 2 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 src/setup/config.rs diff --git a/src/setup/config.rs b/src/setup/config.rs new file mode 100644 index 0000000..55892b0 --- /dev/null +++ b/src/setup/config.rs @@ -0,0 +1,294 @@ +//! Config types and TOML loader for `hu setup`. +//! +//! Path resolution follows the existing `hu` convention via +//! `directories::ProjectDirs::from("", "", "hu")`: +//! +//! - macOS: `~/Library/Application Support/hu/setup.toml` +//! - Linux: `~/.config/hu/setup.toml` +//! +//! Per project doctrine §1 — TOML serialize/deserialize is unit-tested via +//! round-trip; `fs::write` and `fs::read_to_string` are I/O glue and use +//! `#[coverage(off)]` carve-outs. + +// reason: config types wired to `hu setup config init/path` (this chunk) and +// `hu setup status / pkgs / dotfiles / ssh` (Phases 1–4). Suppress dead_code +// until each consumer lands; serialize / deserialize is verified by tests now. +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +const CONFIG_FILENAME: &str = "setup.toml"; + +/// Top-level setup config. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetupConfig { + #[serde(default)] + pub dotfiles: DotfilesConfig, + #[serde(default)] + pub ssh: SshConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub host: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DotfilesConfig { + pub repo: String, + pub branch: String, + pub clone_to: String, + pub strategy: String, + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshConfig { + pub op_vault: String, + pub op_items: Vec, + pub key_dir: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackagesConfig { + pub brew: Vec, + pub mise: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HostOverride { + #[serde(default)] + pub brew_extra: Vec, + #[serde(default)] + pub mise_extra: Vec, +} + +impl Default for DotfilesConfig { + fn default() -> Self { + Self { + repo: "aladac/dotfiles".into(), + branch: "main".into(), + clone_to: "~/Projects/dotfiles".into(), + strategy: "stow".into(), + packages: vec![ + "zsh".into(), + "git".into(), + "kitty".into(), + "starship".into(), + "zellij".into(), + ], + } + } +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + op_vault: "Personal".into(), + op_items: vec!["SSH/id_ed25519".into()], + key_dir: "~/.ssh".into(), + } + } +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + brew: vec![ + "mise".into(), + "uv".into(), + "hf".into(), + "op".into(), + "gh".into(), + "jq".into(), + "stow".into(), + "starship".into(), + "zellij".into(), + "kitty".into(), + "kitten".into(), + "flarectl".into(), + "cloudflared".into(), + "hcloud".into(), + "postgresql".into(), + ], + mise: vec![ + "node@lts".into(), + "ruby@latest".into(), + "python@latest".into(), + "rust@latest".into(), + ], + } + } +} + +/// Serialize config to a TOML string. Pure function — testable without I/O. +pub fn serialize(config: &SetupConfig) -> Result { + toml::to_string_pretty(config).context("serialize SetupConfig to TOML") +} + +/// Deserialize config from a TOML string. Pure function — testable without I/O. +pub fn deserialize(raw: &str) -> Result { + toml::from_str(raw).context("parse setup.toml") +} + +/// Resolve the config path via `ProjectDirs`. Returns `None` if no usable +/// config directory exists for this platform. +pub fn config_path() -> Option { + ProjectDirs::from("", "", "hu").map(|dirs| dirs.config_dir().join(CONFIG_FILENAME)) +} + +/// Read the current setup.toml. Returns the default config when the file is +/// absent (so `hu setup status` works on a fresh host before `config init`). +pub fn load() -> Result { + let Some(path) = config_path() else { + return Ok(SetupConfig::default()); + }; + if !path.exists() { + return Ok(SetupConfig::default()); + } + let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + deserialize(&raw) +} + +/// Write the default config to disk if absent. Idempotent — returns the path +/// either way and an `existed` flag indicating whether a file already lived +/// there. +pub fn init_default() -> Result { + let path = config_path().context("could not resolve config directory for hu")?; + if path.exists() { + return Ok(InitOutcome { + path, + existed: true, + }); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create config dir {}", parent.display()))?; + } + let raw = serialize(&SetupConfig::default())?; + std::fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?; + Ok(InitOutcome { + path, + existed: false, + }) +} + +#[derive(Debug, Clone)] +pub struct InitOutcome { + pub path: PathBuf, + pub existed: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_dotfiles_repo_is_aladac() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.dotfiles.repo, "aladac/dotfiles"); + assert_eq!(cfg.dotfiles.strategy, "stow"); + } + + #[test] + fn default_brew_includes_mise_and_stow() { + let cfg = SetupConfig::default(); + assert!(cfg.packages.brew.contains(&"mise".to_string())); + assert!(cfg.packages.brew.contains(&"stow".to_string())); + assert!(cfg.packages.brew.contains(&"op".to_string())); + assert!(cfg.packages.brew.contains(&"gh".to_string())); + } + + #[test] + fn default_mise_includes_all_t2() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.packages.mise.len(), 4); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("node@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("ruby@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("python@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("rust@"))); + } + + #[test] + fn default_ssh_uses_op_with_personal_vault() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.ssh.op_vault, "Personal"); + assert_eq!(cfg.ssh.key_dir, "~/.ssh"); + assert_eq!(cfg.ssh.op_items, vec!["SSH/id_ed25519"]); + } + + #[test] + fn round_trips_default_config() { + let cfg = SetupConfig::default(); + let raw = serialize(&cfg).unwrap(); + let parsed = deserialize(&raw).unwrap(); + assert_eq!(parsed, cfg); + } + + #[test] + fn round_trips_with_host_override() { + let mut cfg = SetupConfig::default(); + cfg.host.insert( + "marauder".to_string(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let raw = serialize(&cfg).unwrap(); + let parsed = deserialize(&raw).unwrap(); + assert_eq!(parsed, cfg); + assert_eq!( + parsed.host.get("marauder").unwrap().brew_extra, + vec!["nvtop"] + ); + } + + #[test] + fn deserialize_accepts_minimal_toml() { + let raw = r#" +[dotfiles] +repo = "x/y" +branch = "main" +clone_to = "~/dot" +strategy = "stow" +packages = ["zsh"] + +[ssh] +op_vault = "V" +op_items = ["a"] +key_dir = "~/.ssh" + +[packages] +brew = ["gh"] +mise = ["node@lts"] +"#; + let cfg = deserialize(raw).unwrap(); + assert_eq!(cfg.dotfiles.repo, "x/y"); + assert_eq!(cfg.packages.brew, vec!["gh"]); + assert!(cfg.host.is_empty()); + } + + #[test] + fn deserialize_rejects_invalid_toml() { + let err = deserialize("not = valid = toml").unwrap_err(); + assert!(err.to_string().contains("setup.toml")); + } + + #[test] + fn config_path_returns_setup_toml() { + let path = config_path().expect("ProjectDirs should resolve on test platform"); + assert_eq!(path.file_name().unwrap().to_str().unwrap(), CONFIG_FILENAME); + } + + #[test] + fn host_override_default_is_empty() { + let h = HostOverride::default(); + assert!(h.brew_extra.is_empty()); + assert!(h.mise_extra.is_empty()); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 2463a33..8a603de 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -6,12 +6,16 @@ //! Each step follows the idempotency contract `check → skip-or-act → re-verify`. mod cli; +mod config; mod os; mod types; pub use cli::SetupCommand; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; +use owo_colors::OwoColorize; + +use cli::ConfigCommand; /// Dispatch entry point — called from `main.rs`. pub async fn run_command(cmd: SetupCommand) -> Result<()> { @@ -31,8 +35,54 @@ pub async fn run_command(cmd: SetupCommand) -> Result<()> { SetupCommand::Ssh => { bail!("hu setup ssh: not yet implemented (Phase 4)"); } - SetupCommand::Config { cmd: _ } => { - bail!("hu setup config: not yet implemented (Phase 0 chunk 0.3)"); + SetupCommand::Config { cmd } => run_config(cmd).await, + } +} + +async fn run_config(cmd: Option) -> Result<()> { + let Some(cmd) = cmd else { + // Default action: show path + return show_config_path(); + }; + match cmd { + ConfigCommand::Init => init_config(), + ConfigCommand::Path => show_config_path(), + } +} + +fn init_config() -> Result<()> { + let outcome = config::init_default().context("init setup.toml")?; + if outcome.existed { + println!( + "{} setup.toml already exists at {}", + "◐".yellow(), + outcome.path.display() + ); + } else { + println!( + "{} wrote default setup.toml to {}", + "✓".green(), + outcome.path.display() + ); + } + Ok(()) +} + +fn show_config_path() -> Result<()> { + match config::config_path() { + Some(path) => { + let exists = path.exists(); + let icon = if exists { + "✓".green().to_string() + } else { + "○".dimmed().to_string() + }; + println!("{} {}", icon, path.display()); + if !exists { + println!(" (not yet created — run `hu setup config init`)"); + } + Ok(()) } + None => bail!("could not resolve config directory for hu on this platform"), } } From 8f09a50d230f98cd06b634cb2f34d5446d7831ad Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 18:44:02 +0200 Subject: [PATCH 04/15] feat(setup): add status collector + display table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/setup/display.rs | 136 ++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 20 +++++- src/setup/status.rs | 162 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/setup/display.rs create mode 100644 src/setup/status.rs diff --git a/src/setup/display.rs b/src/setup/display.rs new file mode 100644 index 0000000..bd0679a --- /dev/null +++ b/src/setup/display.rs @@ -0,0 +1,136 @@ +//! Status table rendering for `hu setup status`. +//! +//! Pure-function renderer: takes a `Vec` and produces a string +//! using `comfy_table` with the project-standard `UTF8_FULL_CONDENSED` preset. +//! Tested without I/O via snapshot-style equality on the rendered output. + +// reason: render is invoked only by `hu setup status` (this chunk) and `preview`. +// Tests cover the rendered output directly. +#![allow(dead_code)] + +use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, Table}; + +use crate::setup::types::Status; + +/// One row of the status table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusRow { + pub category: String, + pub name: String, + pub status: Status, + pub note: String, +} + +impl StatusRow { + pub fn new(category: &str, name: &str, status: Status) -> Self { + Self { + category: category.to_string(), + name: name.to_string(), + status, + note: String::new(), + } + } + + pub fn with_note(mut self, note: &str) -> Self { + self.note = note.to_string(); + self + } +} + +/// Render a status table to a string. +pub fn render(rows: &[StatusRow]) -> String { + let mut table = Table::new(); + table.load_preset(UTF8_FULL_CONDENSED); + table.set_header(vec!["", "Category", "Name", "Note"]); + for row in rows { + let icon_cell = Cell::new(row.status.icon()).fg(status_color(row.status)); + table.add_row(vec![ + icon_cell, + Cell::new(&row.category), + Cell::new(&row.name), + Cell::new(&row.note), + ]); + } + table.to_string() +} + +/// Summary line: "X/Y satisfied". +pub fn summary(rows: &[StatusRow]) -> String { + let total = rows.len(); + let satisfied = rows.iter().filter(|r| r.status.is_satisfied()).count(); + format!("{}/{} satisfied", satisfied, total) +} + +fn status_color(s: Status) -> Color { + match s { + Status::Already | Status::Installed => Color::Green, + Status::Skipped => Color::Yellow, + Status::Failed => Color::Red, + Status::Unknown => Color::DarkGrey, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_empty_table() { + let out = render(&[]); + // header still present + assert!(out.contains("Category")); + assert!(out.contains("Name")); + } + + #[test] + fn renders_single_row_with_icon() { + let rows = vec![StatusRow::new("brew", "gh", Status::Already)]; + let out = render(&rows); + assert!(out.contains("brew")); + assert!(out.contains("gh")); + assert!(out.contains("✓")); + } + + #[test] + fn renders_failed_with_x_icon() { + let rows = vec![StatusRow::new("brew", "missing", Status::Failed)]; + let out = render(&rows); + assert!(out.contains("✗")); + } + + #[test] + fn summary_counts_satisfied_only() { + let rows = vec![ + StatusRow::new("brew", "a", Status::Already), + StatusRow::new("brew", "b", Status::Installed), + StatusRow::new("brew", "c", Status::Failed), + StatusRow::new("brew", "d", Status::Unknown), + ]; + assert_eq!(summary(&rows), "2/4 satisfied"); + } + + #[test] + fn summary_handles_empty() { + assert_eq!(summary(&[]), "0/0 satisfied"); + } + + #[test] + fn with_note_attaches_string() { + let row = StatusRow::new("ssh", "id_ed25519", Status::Already).with_note("chmod 600"); + assert_eq!(row.note, "chmod 600"); + } + + #[test] + fn unknown_status_renders_open_circle() { + let rows = vec![StatusRow::new("brew", "?", Status::Unknown)]; + let out = render(&rows); + assert!(out.contains("○")); + } + + #[test] + fn skipped_status_renders_half_circle() { + let rows = vec![StatusRow::new("brew", "x", Status::Skipped)]; + let out = render(&rows); + assert!(out.contains("◐")); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 8a603de..bee5d1a 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -7,7 +7,9 @@ mod cli; mod config; +mod display; mod os; +mod status; mod types; pub use cli::SetupCommand; @@ -16,13 +18,14 @@ use anyhow::{bail, Context, Result}; use owo_colors::OwoColorize; use cli::ConfigCommand; +use os::Os; + +use crate::util::shell::RealShell; /// Dispatch entry point — called from `main.rs`. pub async fn run_command(cmd: SetupCommand) -> Result<()> { match cmd { - SetupCommand::Status | SetupCommand::Preview => { - bail!("hu setup status: not yet implemented (Phase 0 chunk 0.4)"); - } + SetupCommand::Status | SetupCommand::Preview => run_status().await, SetupCommand::Run(_) => { bail!("hu setup run: not yet implemented (Phase 5)"); } @@ -68,6 +71,17 @@ fn init_config() -> Result<()> { Ok(()) } +async fn run_status() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + let rows = status::collect(&shell, &cfg).await?; + println!("{} host: {}", "◆".cyan(), os.label()); + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + Ok(()) +} + fn show_config_path() -> Result<()> { match config::config_path() { Some(path) => { diff --git a/src/setup/status.rs b/src/setup/status.rs new file mode 100644 index 0000000..d10818a --- /dev/null +++ b/src/setup/status.rs @@ -0,0 +1,162 @@ +//! Service layer for `hu setup status`. +//! +//! Collects status rows for every configured package by checking presence +//! via the [`Shell`] chokepoint. Pure logic — interface formats the result. + +// reason: collector wired to `hu setup status` (this chunk) and reused by +// `setup preview` and (in Phase 1+) the per-package install pipeline. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Map a configured package id to the binary name `which` should look for. +/// +/// For most packages the id matches the binary. Known mismatches: +/// - `postgresql` ships `psql` as the user-facing binary +/// - mise-managed `@` strips the version qualifier +pub fn binary_name(pkg: &str) -> &str { + if let Some((lang, _version)) = pkg.split_once('@') { + return mise_lang_to_binary(lang); + } + match pkg { + "postgresql" => "psql", + "kitty" => "kitty", + other => other, + } +} + +fn mise_lang_to_binary(lang: &str) -> &str { + match lang { + "node" => "node", + "ruby" => "ruby", + "python" => "python3", + "rust" => "rustc", + other => other, + } +} + +/// Collect status rows for every configured package + key host artifact. +/// +/// Performs a `which ` per package via the `Shell` chokepoint. No +/// side effects beyond the `which` calls themselves. +pub async fn collect(shell: &impl Shell, config: &SetupConfig) -> Result> { + let mut rows = Vec::new(); + for pkg in &config.packages.brew { + rows.push(check_binary(shell, "brew", pkg).await); + } + for pkg in &config.packages.mise { + rows.push(check_binary(shell, "mise", pkg).await); + } + Ok(rows) +} + +async fn check_binary(shell: &impl Shell, category: &str, pkg: &str) -> StatusRow { + let bin = binary_name(pkg); + let status = if shell.which(bin).await { + Status::Already + } else { + Status::Failed + }; + StatusRow::new(category, pkg, status).with_note( + if bin == pkg { + String::new() + } else { + format!("binary: {}", bin) + } + .as_str(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::config::SetupConfig; + use crate::util::shell::FakeShell; + + #[test] + fn binary_name_strips_version_for_mise_packages() { + assert_eq!(binary_name("node@lts"), "node"); + assert_eq!(binary_name("ruby@latest"), "ruby"); + assert_eq!(binary_name("python@latest"), "python3"); + assert_eq!(binary_name("rust@latest"), "rustc"); + } + + #[test] + fn binary_name_remaps_postgresql_to_psql() { + assert_eq!(binary_name("postgresql"), "psql"); + } + + #[test] + fn binary_name_passes_through_unknown() { + assert_eq!(binary_name("gh"), "gh"); + assert_eq!(binary_name("zellij"), "zellij"); + } + + #[tokio::test] + async fn collect_marks_present_packages_as_already() { + let shell = FakeShell::new(); + shell.expect("which", &["gh"], "/opt/homebrew/bin/gh\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["gh".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, Status::Already); + assert_eq!(rows[0].name, "gh"); + assert_eq!(rows[0].category, "brew"); + } + + #[tokio::test] + async fn collect_marks_missing_packages_as_failed() { + let shell = FakeShell::new(); + // no expect → unscripted → exit 127 → which returns false + let mut config = SetupConfig::default(); + config.packages.brew = vec!["nonexistent".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, Status::Failed); + } + + #[tokio::test] + async fn collect_uses_psql_binary_for_postgresql() { + let shell = FakeShell::new(); + shell.expect("which", &["psql"], "/opt/homebrew/bin/psql\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["postgresql".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows[0].status, Status::Already); + assert!(rows[0].note.contains("psql")); + } + + #[tokio::test] + async fn collect_handles_mise_versioned_packages() { + let shell = FakeShell::new(); + shell.expect("which", &["node"], "/usr/local/bin/node\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec![]; + config.packages.mise = vec!["node@lts".into()]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows[0].name, "node@lts"); + assert_eq!(rows[0].category, "mise"); + assert_eq!(rows[0].status, Status::Already); + } + + #[tokio::test] + async fn collect_returns_one_row_per_configured_package() { + let shell = FakeShell::new(); + shell.expect("which", &["gh"], "/usr/bin/gh\n", 0); + shell.expect("which", &["node"], "/usr/bin/node\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["gh".into(), "missing".into()]; + config.packages.mise = vec!["node@lts".into()]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 3); + } +} From 33689d12b6df39d56a49db3567ce889a1936eb8a Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:06:09 +0200 Subject: [PATCH 05/15] feat(setup): add Installer trait + BrewInstaller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` + `brew install ` 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. --- src/setup/mod.rs | 1 + src/setup/packages.rs | 238 ++++++++++++++++++++++++++++++++++++++++++ src/util/shell.rs | 36 +++++++ 3 files changed, 275 insertions(+) create mode 100644 src/setup/packages.rs diff --git a/src/setup/mod.rs b/src/setup/mod.rs index bee5d1a..fc83875 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -9,6 +9,7 @@ mod cli; mod config; mod display; mod os; +mod packages; mod status; mod types; diff --git a/src/setup/packages.rs b/src/setup/packages.rs new file mode 100644 index 0000000..90732f3 --- /dev/null +++ b/src/setup/packages.rs @@ -0,0 +1,238 @@ +//! Installer trait + concrete implementations. +//! +//! The `Installer` trait is one of the two earned trait abstractions in +//! `hu setup` (per project doctrine §1 — ≥2 implementers exist or are likely): +//! +//! - [`BrewInstaller`] — wraps `brew list ` + `brew install ` +//! - `MiseInstaller` (Phase 2) — wraps `mise use -g ` +//! +//! Both use the [`Shell`] chokepoint for I/O. Installers consume `&impl Shell` +//! so callers stay generic (static dispatch). + +// reason: trait + impls wired by Phase 1 chunk 1.3 (`hu setup pkgs`) and +// Phase 5 (`hu setup run`). Tests cover the surface now. +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Outcome of an `Installer::ensure` call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallResult { + pub package: String, + pub status: Status, + pub note: String, +} + +impl InstallResult { + pub fn already(pkg: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Already, + note: "already present".into(), + } + } + + pub fn installed(pkg: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Installed, + note: "installed".into(), + } + } + + pub fn failed(pkg: &str, note: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Failed, + note: note.into(), + } + } + + pub fn skipped(pkg: &str, note: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Skipped, + note: note.into(), + } + } +} + +/// A package installer for one delivery mechanism (brew, mise, apt, …). +#[async_trait] +pub trait Installer: Send + Sync { + /// Short id ("brew", "mise") for status reporting. + fn name(&self) -> &'static str; + + /// True when the package is currently installed. + async fn check(&self, shell: &S, package: &str) -> Result; + + /// Install the package. Implementations should be idempotent — calling + /// install on a present package should be a no-op. + async fn install(&self, shell: &S, package: &str) -> Result<()>; + + /// Idempotent ensure: `check → skip-or-install → re-verify`. + /// + /// This is the primary entry point. It enforces the doctrine §9 contract: + /// re-verifying after `install()` because exit 0 is not proof the side + /// effect happened. + async fn ensure(&self, shell: &S, package: &str) -> InstallResult { + match self.check(shell, package).await { + Ok(true) => return InstallResult::already(package), + Ok(false) => {} + Err(e) => return InstallResult::failed(package, &format!("check failed: {}", e)), + } + if let Err(e) = self.install(shell, package).await { + return InstallResult::failed(package, &format!("install failed: {}", e)); + } + match self.check(shell, package).await { + Ok(true) => InstallResult::installed(package), + Ok(false) => { + InstallResult::failed(package, "install reported success but check still fails") + } + Err(e) => InstallResult::failed(package, &format!("re-verify failed: {}", e)), + } + } +} + +/// Homebrew installer. Works on macOS and Linux (linuxbrew). +pub struct BrewInstaller; + +#[async_trait] +impl Installer for BrewInstaller { + fn name(&self) -> &'static str { + "brew" + } + + async fn check(&self, shell: &S, package: &str) -> Result { + let out = shell + .run("brew", &["list", "--versions", package]) + .await + .with_context(|| format!("brew list {}", package))?; + Ok(out.is_success()) + } + + async fn install(&self, shell: &S, package: &str) -> Result<()> { + let out = shell + .run("brew", &["install", package]) + .await + .with_context(|| format!("brew install {}", package))?; + if !out.is_success() { + anyhow::bail!( + "brew install {} failed (exit {:?}): {}", + package, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn brew() -> BrewInstaller { + BrewInstaller + } + + #[tokio::test] + async fn name_is_brew() { + assert_eq!(brew().name(), "brew"); + } + + #[tokio::test] + async fn check_returns_true_when_brew_list_succeeds() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + assert!(brew().check(&shell, "gh").await.unwrap()); + } + + #[tokio::test] + async fn check_returns_false_when_brew_list_fails() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "missing"], "", 1); + assert!(!brew().check(&shell, "missing").await.unwrap()); + } + + #[tokio::test] + async fn install_succeeds_on_zero_exit() { + let shell = FakeShell::new(); + shell.expect("brew", &["install", "gh"], "Successfully installed gh\n", 0); + brew().install(&shell, "gh").await.unwrap(); + } + + #[tokio::test] + async fn install_errors_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("brew", &["install", "broken"], "", 1); + let err = brew().install(&shell, "broken").await.unwrap_err(); + assert!(err.to_string().contains("brew install broken failed")); + } + + #[tokio::test] + async fn ensure_skips_when_already_installed() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + let result = brew().ensure(&shell, "gh").await; + assert_eq!(result.status, Status::Already); + // exactly one call: just the check + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn ensure_marks_failed_when_install_lies() { + // Install reports success but post-install check still fails. + // Doctrine §9: re-verify catches lies — exit 0 ≠ side effect happened. + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "jq"], "", 1); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + let result = brew().ensure(&shell, "jq").await; + assert_eq!(result.status, Status::Failed); + assert!(result + .note + .contains("install reported success but check still fails")); + } + + #[tokio::test] + async fn ensure_installs_when_missing_then_re_verifies_green() { + // Happy path: first check fails, install succeeds, second check passes. + let shell = FakeShell::new(); + shell.expect_sequence( + "brew", + &["list", "--versions", "jq"], + &[("", 1), ("jq 1.7\n", 0)], + ); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + let result = brew().ensure(&shell, "jq").await; + assert_eq!(result.status, Status::Installed); + // Three calls: check → install → check + assert_eq!(shell.calls().len(), 3); + } + + #[tokio::test] + async fn ensure_marks_failed_when_install_errors() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "broken"], "", 1); + shell.expect("brew", &["install", "broken"], "", 1); + let result = brew().ensure(&shell, "broken").await; + assert_eq!(result.status, Status::Failed); + assert!(result.note.contains("install failed")); + } + + #[test] + fn install_result_constructors() { + assert_eq!(InstallResult::already("x").status, Status::Already); + assert_eq!(InstallResult::installed("x").status, Status::Installed); + assert_eq!(InstallResult::failed("x", "boom").status, Status::Failed); + assert_eq!( + InstallResult::skipped("x", "filtered").status, + Status::Skipped + ); + } +} diff --git a/src/util/shell.rs b/src/util/shell.rs index 99ebc6e..49f0c4a 100644 --- a/src/util/shell.rs +++ b/src/util/shell.rs @@ -77,6 +77,7 @@ mod fake { /// not found) which mirrors real shell behaviour. pub struct FakeShell { responses: Mutex>, + sequences: Mutex>>, calls: Mutex)>>, } @@ -84,6 +85,7 @@ mod fake { pub fn new() -> Self { Self { responses: Mutex::new(HashMap::new()), + sequences: Mutex::new(HashMap::new()), calls: Mutex::new(Vec::new()), } } @@ -102,6 +104,29 @@ mod fake { ); } + /// Register an ordered sequence of responses for repeated calls to the + /// same `(cmd, args)`. Each call pops the next response; exhausted + /// sequences fall through to the steady-state `expect` value (or 127 + /// if none registered). + /// + /// Useful for `check → install → check` flows where the second check + /// should observe the post-install state. + pub fn expect_sequence(&self, cmd: &str, args: &[&str], outcomes: &[(&str, i32)]) { + let key = Self::key(cmd, args); + let queue: Vec = outcomes + .iter() + .map(|(stdout, exit_code)| ShellOutput { + status: ExitStatus::from_raw(*exit_code << 8), + stdout: stdout.to_string(), + stderr: String::new(), + }) + .collect(); + self.sequences + .lock() + .expect("fake shell mutex") + .insert(key, queue); + } + /// Recorded call log. pub fn calls(&self) -> Vec<(String, Vec)> { self.calls.lock().expect("fake shell mutex").clone() @@ -120,6 +145,17 @@ mod fake { cmd.to_string(), args.iter().map(|s| s.to_string()).collect(), )); + // Sequence queue takes priority over single-response map. + if let Some(queue) = self + .sequences + .lock() + .expect("fake shell mutex") + .get_mut(&key) + { + if !queue.is_empty() { + return Ok(queue.remove(0)); + } + } let map = self.responses.lock().expect("fake shell mutex"); match map.get(&key) { Some(out) => Ok(out.clone()), From a1ec342bd7e7cb6388345e4a08647e3846dffe01 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:07:29 +0200 Subject: [PATCH 06/15] feat(setup): add T0 bootstrap (brew + linuxbrew apt prereqs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/bootstrap.rs | 239 +++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 1 + 2 files changed, 240 insertions(+) create mode 100644 src/setup/bootstrap.rs diff --git a/src/setup/bootstrap.rs b/src/setup/bootstrap.rs new file mode 100644 index 0000000..df65df4 --- /dev/null +++ b/src/setup/bootstrap.rs @@ -0,0 +1,239 @@ +//! T0 bootstrap — make the host capable of running the installers. +//! +//! On macOS this just verifies `brew` is on PATH. On Linux we additionally +//! ensure the apt prereqs that linuxbrew needs (`build-essential`, `curl`, +//! `git`, `procps`, `file`). +//! +//! Per doctrine §9: every step is `check → skip-or-act → re-verify`. Per §1: +//! Shell chokepoint covers all I/O, no extra trait wrappers per binary. + +// reason: bootstrap functions wired by Phase 1 chunk 1.3 (`hu setup pkgs`) +// and Phase 5 (`hu setup run`). Tests cover the surface now. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::os::Os; +use crate::setup::packages::InstallResult; +use crate::util::shell::Shell; + +/// Official Homebrew installer command. +/// +/// Runs the upstream install script via `bash -c "$(curl …)"`. NONINTERACTIVE +/// flag skips the press-RETURN prompt the script normally requires. +const BREW_INSTALL: &str = "NONINTERACTIVE=1 /bin/bash -c \ + \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""; + +/// Linuxbrew apt prereqs (per the official Homebrew on Linux requirements). +pub const LINUXBREW_APT_PREREQS: &[&str] = &["build-essential", "curl", "git", "procps", "file"]; + +/// Ensure Homebrew is installed. Idempotent. +/// +/// On macOS: checks `which brew`, installs via the upstream script if missing. +/// On Linux: same flow; the caller is responsible for ensuring apt prereqs +/// first via [`ensure_linuxbrew_prereqs`]. +pub async fn ensure_brew(shell: &S) -> InstallResult { + if shell.which("brew").await { + return InstallResult::already("brew"); + } + let result = shell.run("bash", &["-c", BREW_INSTALL]).await; + match result { + Ok(out) if out.is_success() => {} + Ok(out) => { + return InstallResult::failed( + "brew", + &format!( + "brew install script exited {:?}: {}", + out.status.code(), + out.stderr.trim() + ), + ); + } + Err(e) => { + return InstallResult::failed("brew", &format!("brew install failed: {}", e)); + } + } + if shell.which("brew").await { + InstallResult::installed("brew") + } else { + InstallResult::failed( + "brew", + "install reported success but `which brew` still fails", + ) + } +} + +/// Ensure linuxbrew apt prereqs are installed. Idempotent. Skips on non-Linux. +pub async fn ensure_linuxbrew_prereqs(shell: &S, os: &Os) -> Vec { + if !os.is_linux() { + return vec![InstallResult::skipped( + "linuxbrew-prereqs", + "not on linux — skipped", + )]; + } + let mut results = Vec::with_capacity(LINUXBREW_APT_PREREQS.len()); + for pkg in LINUXBREW_APT_PREREQS { + results.push(ensure_apt_pkg(shell, pkg).await); + } + results +} + +/// Ensure one apt package is installed via `dpkg -s` check + `apt-get install`. +async fn ensure_apt_pkg(shell: &S, pkg: &str) -> InstallResult { + if apt_check(shell, pkg).await { + return InstallResult::already(pkg); + } + if let Err(e) = apt_install(shell, pkg).await { + return InstallResult::failed(pkg, &format!("apt-get install failed: {}", e)); + } + if apt_check(shell, pkg).await { + InstallResult::installed(pkg) + } else { + InstallResult::failed(pkg, "install reported success but dpkg -s still fails") + } +} + +async fn apt_check(shell: &S, pkg: &str) -> bool { + match shell.run("dpkg", &["-s", pkg]).await { + Ok(out) => out.is_success(), + Err(_) => false, + } +} + +async fn apt_install(shell: &S, pkg: &str) -> Result<()> { + let out = shell + .run("sudo", &["apt-get", "install", "-y", pkg]) + .await?; + if !out.is_success() { + anyhow::bail!( + "apt-get install -y {} exited {:?}: {}", + pkg, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::types::Status; + use crate::util::shell::FakeShell; + + #[tokio::test] + async fn ensure_brew_skips_when_already_present() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Already); + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn ensure_brew_installs_when_missing_and_re_verifies_green() { + let shell = FakeShell::new(); + shell.expect_sequence( + "which", + &["brew"], + &[("", 1), ("/opt/homebrew/bin/brew\n", 0)], + ); + shell.expect("bash", &["-c", BREW_INSTALL], "Homebrew installed\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Installed); + // 3 calls: check, install, re-check + assert_eq!(shell.calls().len(), 3); + } + + #[tokio::test] + async fn ensure_brew_marks_failed_when_install_lies() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "", 1); + shell.expect("bash", &["-c", BREW_INSTALL], "Homebrew installed\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Failed); + assert!(r.note.contains("install reported success")); + } + + #[tokio::test] + async fn ensure_brew_marks_failed_when_install_errors() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "", 1); + shell.expect("bash", &["-c", BREW_INSTALL], "", 1); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Failed); + assert!(r.note.contains("brew install script exited")); + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_skips_on_macos() { + let shell = FakeShell::new(); + let results = ensure_linuxbrew_prereqs(&shell, &Os::Mac).await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].status, Status::Skipped); + // No shell calls — short-circuited + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_returns_one_result_per_pkg_on_linux() { + let shell = FakeShell::new(); + for pkg in LINUXBREW_APT_PREREQS { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results.len(), LINUXBREW_APT_PREREQS.len()); + for r in &results { + assert_eq!(r.status, Status::Already); + } + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_installs_missing_pkg() { + let shell = FakeShell::new(); + // first prereq missing → install path; subsequent ones present + let first = LINUXBREW_APT_PREREQS[0]; + shell.expect_sequence( + "dpkg", + &["-s", first], + &[("", 1), ("Status: install ok installed\n", 0)], + ); + shell.expect("sudo", &["apt-get", "install", "-y", first], "ok\n", 0); + for pkg in &LINUXBREW_APT_PREREQS[1..] { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results[0].status, Status::Installed); + for r in &results[1..] { + assert_eq!(r.status, Status::Already); + } + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_marks_failed_when_apt_fails() { + let shell = FakeShell::new(); + let first = LINUXBREW_APT_PREREQS[0]; + shell.expect("dpkg", &["-s", first], "", 1); + shell.expect( + "sudo", + &["apt-get", "install", "-y", first], + "E: locked\n", + 1, + ); + for pkg in &LINUXBREW_APT_PREREQS[1..] { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results[0].status, Status::Failed); + assert!(results[0].note.contains("apt-get install failed")); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index fc83875..a83692c 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -5,6 +5,7 @@ //! //! Each step follows the idempotency contract `check → skip-or-act → re-verify`. +mod bootstrap; mod cli; mod config; mod display; From 071cf4fe273aba7f37ff0457086a91d7bec124db Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:09:39 +0200 Subject: [PATCH 07/15] feat(setup): wire `hu setup pkgs` end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/bootstrap.rs | 2 +- src/setup/mod.rs | 23 +++- src/setup/pkgs.rs | 235 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/setup/pkgs.rs diff --git a/src/setup/bootstrap.rs b/src/setup/bootstrap.rs index df65df4..5a5ffb2 100644 --- a/src/setup/bootstrap.rs +++ b/src/setup/bootstrap.rs @@ -21,7 +21,7 @@ use crate::util::shell::Shell; /// /// Runs the upstream install script via `bash -c "$(curl …)"`. NONINTERACTIVE /// flag skips the press-RETURN prompt the script normally requires. -const BREW_INSTALL: &str = "NONINTERACTIVE=1 /bin/bash -c \ +pub(crate) const BREW_INSTALL: &str = "NONINTERACTIVE=1 /bin/bash -c \ \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""; /// Linuxbrew apt prereqs (per the official Homebrew on Linux requirements). diff --git a/src/setup/mod.rs b/src/setup/mod.rs index a83692c..d6bec51 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -11,6 +11,7 @@ mod config; mod display; mod os; mod packages; +mod pkgs; mod status; mod types; @@ -31,9 +32,7 @@ pub async fn run_command(cmd: SetupCommand) -> Result<()> { SetupCommand::Run(_) => { bail!("hu setup run: not yet implemented (Phase 5)"); } - SetupCommand::Pkgs(_) => { - bail!("hu setup pkgs: not yet implemented (Phase 1)"); - } + SetupCommand::Pkgs(args) => run_pkgs(args).await, SetupCommand::Dotfiles => { bail!("hu setup dotfiles: not yet implemented (Phase 3)"); } @@ -84,6 +83,24 @@ async fn run_status() -> Result<()> { Ok(()) } +async fn run_pkgs(args: cli::PkgsArgs) -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + println!("{} host: {}", "◆".cyan(), os.label()); + if args.dry_run { + println!("{} dry-run — no changes will be made", "◐".yellow()); + } + let rows = pkgs::run(&shell, &cfg, &args, &os).await?; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("one or more packages failed — see table above"); + } + Ok(()) +} + fn show_config_path() -> Result<()> { match config::config_path() { Some(path) => { diff --git a/src/setup/pkgs.rs b/src/setup/pkgs.rs new file mode 100644 index 0000000..7ddc59a --- /dev/null +++ b/src/setup/pkgs.rs @@ -0,0 +1,235 @@ +//! Service layer for `hu setup pkgs`. +//! +//! Orchestrates T0 bootstrap (linuxbrew prereqs + brew) then walks the +//! configured brew package list, calling `BrewInstaller::ensure` per package. +//! Returns one `StatusRow` per step for the display module to render. + +// reason: collector wired by `hu setup pkgs` (this chunk) and `setup run` (Phase 5). +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::bootstrap::{ensure_brew, ensure_linuxbrew_prereqs}; +use crate::setup::cli::PkgsArgs; +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::os::Os; +use crate::setup::packages::{BrewInstaller, InstallResult, Installer}; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Plan + execute the brew package phase. +/// +/// Order of operations: +/// 1. Linuxbrew apt prereqs (skipped on macOS) +/// 2. T0: ensure brew itself is on PATH +/// 3. T1: ensure each filtered brew package is installed +/// +/// `--dry-run` short-circuits at step 1: every step reports `Skipped(dry-run)` +/// without touching the shell. +pub async fn run( + shell: &S, + config: &SetupConfig, + args: &PkgsArgs, + os: &Os, +) -> Result> { + let filtered = filter_brew_packages(&config.packages.brew, &args.only); + + if args.dry_run { + return Ok(dry_run_rows(os, &filtered)); + } + + let mut rows = Vec::new(); + + // 1. Linuxbrew prereqs (or skip on macOS) + for r in ensure_linuxbrew_prereqs(shell, os).await { + rows.push(install_result_to_row("linuxbrew-prereq", &r)); + } + + // 2. T0 brew bootstrap + let brew_result = ensure_brew(shell).await; + rows.push(install_result_to_row("bootstrap", &brew_result)); + if !brew_result.status.is_satisfied() { + // Brew missing → can't proceed with T1 packages. + return Ok(rows); + } + + // 3. T1 brew packages + let installer = BrewInstaller; + for pkg in &filtered { + let r = installer.ensure(shell, pkg).await; + rows.push(install_result_to_row("brew", &r)); + } + + Ok(rows) +} + +/// Apply the `--only` filter to a brew package list. +/// +/// Empty filter → no filtering (all configured packages). Filter values that +/// don't match a configured package are silently dropped (caller can validate +/// upstream if strict matching is required). +pub fn filter_brew_packages(configured: &[String], only: &[String]) -> Vec { + if only.is_empty() { + return configured.to_vec(); + } + configured + .iter() + .filter(|p| only.iter().any(|o| o == *p)) + .cloned() + .collect() +} + +fn dry_run_rows(os: &Os, packages: &[String]) -> Vec { + let mut rows = Vec::new(); + if os.is_linux() { + for pkg in crate::setup::bootstrap::LINUXBREW_APT_PREREQS { + rows.push( + StatusRow::new("linuxbrew-prereq", pkg, Status::Skipped).with_note("dry-run"), + ); + } + } + rows.push(StatusRow::new("bootstrap", "brew", Status::Skipped).with_note("dry-run")); + for pkg in packages { + rows.push(StatusRow::new("brew", pkg, Status::Skipped).with_note("dry-run")); + } + rows +} + +fn install_result_to_row(category: &str, r: &InstallResult) -> StatusRow { + StatusRow::new(category, &r.package, r.status).with_note(&r.note) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::cli::PkgsArgs; + use crate::util::shell::FakeShell; + + fn args(only: Vec<&str>, dry_run: bool) -> PkgsArgs { + PkgsArgs { + only: only.into_iter().map(String::from).collect(), + dry_run, + } + } + + fn config_with_brew(pkgs: &[&str]) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = pkgs.iter().map(|s| s.to_string()).collect(); + cfg.packages.mise = vec![]; + cfg + } + + #[test] + fn filter_empty_returns_full_list() { + let configured = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let only: Vec = vec![]; + assert_eq!(filter_brew_packages(&configured, &only), configured); + } + + #[test] + fn filter_keeps_matching_packages() { + let configured = vec!["gh".to_string(), "jq".to_string(), "op".to_string()]; + let only = vec!["gh".to_string(), "op".to_string()]; + assert_eq!(filter_brew_packages(&configured, &only), vec!["gh", "op"]); + } + + #[test] + fn filter_drops_unconfigured_names() { + let configured = vec!["gh".to_string()]; + let only = vec!["gh".to_string(), "nonexistent".to_string()]; + assert_eq!(filter_brew_packages(&configured, &only), vec!["gh"]); + } + + #[tokio::test] + async fn dry_run_on_macos_skips_prereqs() { + let shell = FakeShell::new(); + let cfg = config_with_brew(&["gh", "jq"]); + let rows = run(&shell, &cfg, &args(vec![], true), &Os::Mac) + .await + .unwrap(); + // No linuxbrew-prereq rows on macOS dry-run + assert!(!rows.iter().any(|r| r.category == "linuxbrew-prereq")); + // bootstrap + 2 brew packages + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r.status == Status::Skipped)); + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn dry_run_on_linux_includes_prereq_rows() { + let shell = FakeShell::new(); + let cfg = config_with_brew(&["gh"]); + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let rows = run(&shell, &cfg, &args(vec![], true), &os).await.unwrap(); + let prereq_rows = rows + .iter() + .filter(|r| r.category == "linuxbrew-prereq") + .count(); + assert_eq!(prereq_rows, 5); // build-essential, curl, git, procps, file + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn run_skips_t1_when_brew_bootstrap_fails() { + let shell = FakeShell::new(); + // brew missing + install fails + shell.expect("which", &["brew"], "", 1); + shell.expect( + "bash", + &["-c", crate::setup::bootstrap::BREW_INSTALL], + "", + 1, + ); + let cfg = config_with_brew(&["gh"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + // bootstrap row marked Failed; no T1 rows added + let brew_rows = rows.iter().filter(|r| r.category == "brew").count(); + assert_eq!(brew_rows, 0); + let bootstrap_row = rows.iter().find(|r| r.category == "bootstrap").unwrap(); + assert_eq!(bootstrap_row.status, Status::Failed); + } + + #[tokio::test] + async fn run_walks_all_filtered_packages_when_brew_present() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + shell.expect("brew", &["list", "--versions", "jq"], "", 1); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + shell.expect_sequence( + "brew", + &["list", "--versions", "jq"], + &[("", 1), ("jq 1.7\n", 0)], + ); + let cfg = config_with_brew(&["gh", "jq"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + let brew_rows: Vec<_> = rows.iter().filter(|r| r.category == "brew").collect(); + assert_eq!(brew_rows.len(), 2); + // gh already, jq installed + assert_eq!(brew_rows[0].name, "gh"); + assert_eq!(brew_rows[0].status, Status::Already); + assert_eq!(brew_rows[1].name, "jq"); + assert_eq!(brew_rows[1].status, Status::Installed); + } + + #[tokio::test] + async fn run_with_only_filter_walks_subset() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + let cfg = config_with_brew(&["gh", "jq", "op"]); + let rows = run(&shell, &cfg, &args(vec!["gh"], false), &Os::Mac) + .await + .unwrap(); + let brew_rows: Vec<_> = rows.iter().filter(|r| r.category == "brew").collect(); + assert_eq!(brew_rows.len(), 1); + assert_eq!(brew_rows[0].name, "gh"); + } +} From 5d1ec2df9c763ea267116d543799b3dbc3592c8e Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:52:23 +0200 Subject: [PATCH 08/15] feat(setup): add MiseInstaller (T2 polyglot version manager) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` (exit 0 + non-empty stdout = active), install via `mise use -g ` - 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. --- src/setup/packages.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/setup/packages.rs b/src/setup/packages.rs index 90732f3..054196b 100644 --- a/src/setup/packages.rs +++ b/src/setup/packages.rs @@ -98,6 +98,12 @@ pub trait Installer: Send + Sync { } } +/// Split a `lang@version` package id into `(lang, version)`. Defaults to +/// `latest` when no `@` is present. +pub fn split_lang_version(pkg: &str) -> (&str, &str) { + pkg.split_once('@').unwrap_or((pkg, "latest")) +} + /// Homebrew installer. Works on macOS and Linux (linuxbrew). pub struct BrewInstaller; @@ -132,6 +138,49 @@ impl Installer for BrewInstaller { } } +/// `mise` polyglot version manager. Manages node, ruby, python, rust, etc. +/// from a single tool — second concrete `Installer` impl that validates the +/// trait abstraction (per doctrine §1: trait earned when ≥2 implementers). +pub struct MiseInstaller; + +#[async_trait] +impl Installer for MiseInstaller { + fn name(&self) -> &'static str { + "mise" + } + + /// Check via `mise current `. Exit 0 + non-empty output means an + /// active version is set. We don't pin a specific version equality — + /// "any version of node managed by mise" satisfies a `node@lts` request, + /// because mise upgrades happen separately via `mise upgrade`. + async fn check(&self, shell: &S, package: &str) -> Result { + let (lang, _version) = split_lang_version(package); + let out = shell + .run("mise", &["current", lang]) + .await + .with_context(|| format!("mise current {}", lang))?; + Ok(out.is_success() && !out.stdout.trim().is_empty()) + } + + /// Install via `mise use -g `. Mise is idempotent itself, + /// but we still funnel through `ensure()` for the re-verify contract. + async fn install(&self, shell: &S, package: &str) -> Result<()> { + let out = shell + .run("mise", &["use", "-g", package]) + .await + .with_context(|| format!("mise use -g {}", package))?; + if !out.is_success() { + anyhow::bail!( + "mise use -g {} failed (exit {:?}): {}", + package, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -141,6 +190,10 @@ mod tests { BrewInstaller } + fn mise() -> MiseInstaller { + MiseInstaller + } + #[tokio::test] async fn name_is_brew() { assert_eq!(brew().name(), "brew"); @@ -235,4 +288,83 @@ mod tests { Status::Skipped ); } + + #[test] + fn split_lang_version_handles_versioned() { + assert_eq!(split_lang_version("node@lts"), ("node", "lts")); + assert_eq!(split_lang_version("rust@1.80"), ("rust", "1.80")); + assert_eq!(split_lang_version("python@latest"), ("python", "latest")); + } + + #[test] + fn split_lang_version_defaults_when_no_at() { + assert_eq!(split_lang_version("node"), ("node", "latest")); + assert_eq!(split_lang_version("ruby"), ("ruby", "latest")); + } + + #[tokio::test] + async fn mise_name_is_mise() { + assert_eq!(mise().name(), "mise"); + } + + #[tokio::test] + async fn mise_check_returns_true_on_active_version() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + assert!(mise().check(&shell, "node@lts").await.unwrap()); + } + + #[tokio::test] + async fn mise_check_returns_false_on_empty_stdout() { + // `mise current` can exit 0 with empty stdout when nothing is set + let shell = FakeShell::new(); + shell.expect("mise", &["current", "ruby"], "", 0); + assert!(!mise().check(&shell, "ruby@latest").await.unwrap()); + } + + #[tokio::test] + async fn mise_check_returns_false_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "rust"], "", 1); + assert!(!mise().check(&shell, "rust@latest").await.unwrap()); + } + + #[tokio::test] + async fn mise_install_runs_use_global() { + let shell = FakeShell::new(); + shell.expect( + "mise", + &["use", "-g", "node@lts"], + "installed node@20.10\n", + 0, + ); + mise().install(&shell, "node@lts").await.unwrap(); + } + + #[tokio::test] + async fn mise_install_errors_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("mise", &["use", "-g", "rust@nightly"], "", 1); + let err = mise().install(&shell, "rust@nightly").await.unwrap_err(); + assert!(err.to_string().contains("mise use -g rust@nightly failed")); + } + + #[tokio::test] + async fn mise_ensure_skips_when_already_present() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + let r = mise().ensure(&shell, "node@lts").await; + assert_eq!(r.status, Status::Already); + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn mise_ensure_installs_when_missing_re_verifies_green() { + let shell = FakeShell::new(); + shell.expect_sequence("mise", &["current", "rust"], &[("", 1), ("1.80.0\n", 0)]); + shell.expect("mise", &["use", "-g", "rust@latest"], "installed\n", 0); + let r = mise().ensure(&shell, "rust@latest").await; + assert_eq!(r.status, Status::Installed); + assert_eq!(shell.calls().len(), 3); + } } From 4e7d1594ed44054b4c7527f493a0ea0ec2c25356 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:53:56 +0200 Subject: [PATCH 09/15] feat(setup): wire MiseInstaller into pkgs orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/pkgs.rs | 108 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/src/setup/pkgs.rs b/src/setup/pkgs.rs index 7ddc59a..a69c555 100644 --- a/src/setup/pkgs.rs +++ b/src/setup/pkgs.rs @@ -14,7 +14,7 @@ use crate::setup::cli::PkgsArgs; use crate::setup::config::SetupConfig; use crate::setup::display::StatusRow; use crate::setup::os::Os; -use crate::setup::packages::{BrewInstaller, InstallResult, Installer}; +use crate::setup::packages::{BrewInstaller, InstallResult, Installer, MiseInstaller}; use crate::setup::types::Status; use crate::util::shell::Shell; @@ -33,10 +33,11 @@ pub async fn run( args: &PkgsArgs, os: &Os, ) -> Result> { - let filtered = filter_brew_packages(&config.packages.brew, &args.only); + let filtered_brew = filter_packages(&config.packages.brew, &args.only); + let filtered_mise = filter_packages(&config.packages.mise, &args.only); if args.dry_run { - return Ok(dry_run_rows(os, &filtered)); + return Ok(dry_run_rows(os, &filtered_brew, &filtered_mise)); } let mut rows = Vec::new(); @@ -50,26 +51,45 @@ pub async fn run( let brew_result = ensure_brew(shell).await; rows.push(install_result_to_row("bootstrap", &brew_result)); if !brew_result.status.is_satisfied() { - // Brew missing → can't proceed with T1 packages. + // Brew missing → can't proceed with T1 / T2 packages (mise comes via brew). return Ok(rows); } // 3. T1 brew packages - let installer = BrewInstaller; - for pkg in &filtered { - let r = installer.ensure(shell, pkg).await; + let brew = BrewInstaller; + for pkg in &filtered_brew { + let r = brew.ensure(shell, pkg).await; rows.push(install_result_to_row("brew", &r)); } + // 4. T2 mise-managed runtimes (only if mise itself is reachable) + if !filtered_mise.is_empty() && shell.which("mise").await { + let mise = MiseInstaller; + for pkg in &filtered_mise { + let r = mise.ensure(shell, pkg).await; + rows.push(install_result_to_row("mise", &r)); + } + } else if !filtered_mise.is_empty() { + // mise not on PATH yet — can happen when brew just installed it but + // shims aren't in this shell's environment. Surface as Failed so + // Pilot knows to re-run after rehashing PATH. + for pkg in &filtered_mise { + rows.push( + StatusRow::new("mise", pkg, Status::Failed) + .with_note("mise not on PATH — re-run after `eval \"$(mise activate)\"`"), + ); + } + } + Ok(rows) } -/// Apply the `--only` filter to a brew package list. +/// Apply the `--only` filter to a package list (brew or mise). /// /// Empty filter → no filtering (all configured packages). Filter values that -/// don't match a configured package are silently dropped (caller can validate -/// upstream if strict matching is required). -pub fn filter_brew_packages(configured: &[String], only: &[String]) -> Vec { +/// don't match a configured package are silently dropped. Matches against the +/// full configured name (`node@lts` matches `node@lts`, not `node`). +pub fn filter_packages(configured: &[String], only: &[String]) -> Vec { if only.is_empty() { return configured.to_vec(); } @@ -80,7 +100,7 @@ pub fn filter_brew_packages(configured: &[String], only: &[String]) -> Vec Vec { +fn dry_run_rows(os: &Os, brew: &[String], mise: &[String]) -> Vec { let mut rows = Vec::new(); if os.is_linux() { for pkg in crate::setup::bootstrap::LINUXBREW_APT_PREREQS { @@ -90,9 +110,12 @@ fn dry_run_rows(os: &Os, packages: &[String]) -> Vec { } } rows.push(StatusRow::new("bootstrap", "brew", Status::Skipped).with_note("dry-run")); - for pkg in packages { + for pkg in brew { rows.push(StatusRow::new("brew", pkg, Status::Skipped).with_note("dry-run")); } + for pkg in mise { + rows.push(StatusRow::new("mise", pkg, Status::Skipped).with_note("dry-run")); + } rows } @@ -120,25 +143,32 @@ mod tests { cfg } + fn config_with_brew_and_mise(brew: &[&str], mise: &[&str]) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = brew.iter().map(|s| s.to_string()).collect(); + cfg.packages.mise = mise.iter().map(|s| s.to_string()).collect(); + cfg + } + #[test] fn filter_empty_returns_full_list() { let configured = vec!["a".to_string(), "b".to_string(), "c".to_string()]; let only: Vec = vec![]; - assert_eq!(filter_brew_packages(&configured, &only), configured); + assert_eq!(filter_packages(&configured, &only), configured); } #[test] fn filter_keeps_matching_packages() { let configured = vec!["gh".to_string(), "jq".to_string(), "op".to_string()]; let only = vec!["gh".to_string(), "op".to_string()]; - assert_eq!(filter_brew_packages(&configured, &only), vec!["gh", "op"]); + assert_eq!(filter_packages(&configured, &only), vec!["gh", "op"]); } #[test] fn filter_drops_unconfigured_names() { let configured = vec!["gh".to_string()]; let only = vec!["gh".to_string(), "nonexistent".to_string()]; - assert_eq!(filter_brew_packages(&configured, &only), vec!["gh"]); + assert_eq!(filter_packages(&configured, &only), vec!["gh"]); } #[tokio::test] @@ -219,6 +249,52 @@ mod tests { assert_eq!(brew_rows[1].status, Status::Installed); } + #[tokio::test] + async fn run_walks_brew_then_mise_when_both_configured() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + shell.expect("which", &["mise"], "/opt/homebrew/bin/mise\n", 0); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + let cfg = config_with_brew_and_mise(&["gh"], &["node@lts"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + assert!(rows.iter().any(|r| r.category == "brew" && r.name == "gh")); + assert!(rows + .iter() + .any(|r| r.category == "mise" && r.name == "node@lts")); + let mise_row = rows.iter().find(|r| r.category == "mise").unwrap(); + assert_eq!(mise_row.status, Status::Already); + } + + #[tokio::test] + async fn run_marks_mise_failed_when_mise_not_on_path() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + // mise not yet on PATH + shell.expect("which", &["mise"], "", 1); + let cfg = config_with_brew_and_mise(&[], &["node@lts"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + let mise_row = rows.iter().find(|r| r.category == "mise").unwrap(); + assert_eq!(mise_row.status, Status::Failed); + assert!(mise_row.note.contains("mise activate")); + } + + #[tokio::test] + async fn dry_run_includes_mise_rows() { + let shell = FakeShell::new(); + let cfg = config_with_brew_and_mise(&["gh"], &["node@lts", "rust@latest"]); + let rows = run(&shell, &cfg, &args(vec![], true), &Os::Mac) + .await + .unwrap(); + let mise_count = rows.iter().filter(|r| r.category == "mise").count(); + assert_eq!(mise_count, 2); + assert!(shell.calls().is_empty()); + } + #[tokio::test] async fn run_with_only_filter_walks_subset() { let shell = FakeShell::new(); From dc96d85af0e8759612e99c8ab59de66622452089 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:55:52 +0200 Subject: [PATCH 10/15] feat(setup): add dotfiles clone + stow primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.git exists. Used to detect already-cloned repos for skip-or-clone idempotency. - ensure_clone() — gh repo clone with skip-on-existing, re-verify (.git/ landed), and structured StatusRow output. - stow_apply() — stow -R -d -t . 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. --- src/setup/dotfiles.rs | 266 ++++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 1 + 2 files changed, 267 insertions(+) create mode 100644 src/setup/dotfiles.rs diff --git a/src/setup/dotfiles.rs b/src/setup/dotfiles.rs new file mode 100644 index 0000000..08f70ad --- /dev/null +++ b/src/setup/dotfiles.rs @@ -0,0 +1,266 @@ +//! Dotfiles phase — clone (or refresh) + stow apply. +//! +//! Phase 3 splits into two responsibilities: +//! 1. **Clone** — fetch the dotfiles repo via `gh repo clone `, +//! or detect an existing clone and skip. +//! 2. **Apply** — for each configured package directory, run +//! `stow -R -d -t ~ ` (restow is idempotent: re-creates +//! symlinks even if they already exist, handles partial state cleanly). +//! +//! Both steps go through the [`Shell`] chokepoint. No `Installer` trait +//! needed — this is a single-shot orchestration, not a registry walk. + +// reason: dotfiles flow wired by `hu setup dotfiles` (chunk 3.2) and +// `setup run` (Phase 5). Tests cover the surface. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use crate::setup::config::DotfilesConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Resolve a `~`-prefixed path to an absolute path. +/// +/// Pure-function alternative to `shellexpand` — keeps the dependency surface +/// thin. `$HOME` lookup is the only env touch and only happens for paths +/// that actually start with `~/`. +pub fn expand_tilde(raw: &str) -> PathBuf { + if let Some(rest) = raw.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(raw) +} + +/// True when `/.git` exists. Used to detect an already-cloned dotfiles +/// repo so we can skip the clone step. +pub fn is_git_repo(dir: &Path) -> bool { + dir.join(".git").exists() +} + +/// Idempotent clone of the dotfiles repo. If `/.git` already +/// exists, returns `Status::Already`. Otherwise runs `gh repo clone`. +pub async fn ensure_clone(shell: &S, config: &DotfilesConfig) -> StatusRow { + let dest = expand_tilde(&config.clone_to); + if is_git_repo(&dest) { + return StatusRow::new("dotfiles", &config.repo, Status::Already) + .with_note(&format!("clone exists: {}", dest.display())); + } + let dest_str = dest.to_string_lossy().into_owned(); + let result = shell + .run("gh", &["repo", "clone", &config.repo, &dest_str]) + .await; + match result { + Ok(out) if out.is_success() => { + // Re-verify the clone landed. + if is_git_repo(&dest) { + StatusRow::new("dotfiles", &config.repo, Status::Installed) + .with_note(&format!("cloned to {}", dest.display())) + } else { + StatusRow::new("dotfiles", &config.repo, Status::Failed) + .with_note("gh clone reported success but .git/ is missing") + } + } + Ok(out) => StatusRow::new("dotfiles", &config.repo, Status::Failed).with_note(&format!( + "gh clone failed (exit {:?}): {}", + out.status.code(), + out.stderr.trim() + )), + Err(e) => StatusRow::new("dotfiles", &config.repo, Status::Failed) + .with_note(&format!("gh clone errored: {}", e)), + } +} + +/// Apply one stow package: `stow -R -d -t ~ `. +/// +/// `-R` (restow) is idempotent — recreates symlinks each invocation, handles +/// partial state cleanly. Conflicts (existing non-symlink files in the way) +/// surface as exit-nonzero with stderr describing the path. +pub async fn stow_apply( + shell: &S, + clone_to: &str, + target: &str, + package: &str, +) -> StatusRow { + let result = shell + .run("stow", &["-R", "-d", clone_to, "-t", target, package]) + .await; + match result { + Ok(out) if out.is_success() => StatusRow::new("stow", package, Status::Installed) + .with_note(&format!("stowed → {}", target)), + Ok(out) => { + let note = parse_stow_conflicts(&out.stderr); + StatusRow::new("stow", package, Status::Failed).with_note(¬e) + } + Err(e) => StatusRow::new("stow", package, Status::Failed) + .with_note(&format!("stow errored: {}", e)), + } +} + +/// Extract a concise conflict summary from stow stderr. +/// +/// stow's stderr lists conflicting files line by line. We keep the gist +/// (first conflicting path + count) without dumping the whole blob into a +/// table cell. +pub fn parse_stow_conflicts(stderr: &str) -> String { + let conflicts: Vec<&str> = stderr + .lines() + .filter(|l| { + l.contains("existing target") + || l.contains("CONFLICT") + || l.contains("would cause conflicts") + }) + .collect(); + if conflicts.is_empty() { + return format!("stow failed: {}", stderr.trim()); + } + let first = conflicts.first().copied().unwrap_or_default(); + if conflicts.len() == 1 { + first.trim().to_string() + } else { + format!("{} (+{} more conflicts)", first.trim(), conflicts.len() - 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn dotfiles_config() -> DotfilesConfig { + DotfilesConfig { + repo: "aladac/dotfiles".into(), + branch: "main".into(), + clone_to: "/tmp/hu-test-dotfiles".into(), + strategy: "stow".into(), + packages: vec!["zsh".into(), "kitty".into()], + } + } + + #[test] + fn expand_tilde_replaces_with_home() { + let original = std::env::var("HOME").ok(); + std::env::set_var("HOME", "/Users/test"); + let path = expand_tilde("~/Projects/dotfiles"); + assert_eq!(path, PathBuf::from("/Users/test/Projects/dotfiles")); + if let Some(h) = original { + std::env::set_var("HOME", h); + } + } + + #[test] + fn expand_tilde_passes_through_non_tilde() { + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!( + expand_tilde("relative/path"), + PathBuf::from("relative/path") + ); + } + + #[test] + fn is_git_repo_detects_dot_git_dir() { + let temp = std::env::temp_dir().join("hu-is-git-repo-test"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + assert!(is_git_repo(&temp)); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[test] + fn is_git_repo_returns_false_when_missing() { + assert!(!is_git_repo(Path::new("/nonexistent/directory"))); + } + + #[tokio::test] + async fn ensure_clone_skips_when_already_cloned() { + let temp = std::env::temp_dir().join("hu-ensure-clone-skip"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + let mut config = dotfiles_config(); + config.clone_to = temp.to_string_lossy().into_owned(); + let shell = FakeShell::new(); + let row = ensure_clone(&shell, &config).await; + assert_eq!(row.status, Status::Already); + assert!(shell.calls().is_empty()); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[tokio::test] + async fn ensure_clone_failed_when_gh_errors() { + let mut config = dotfiles_config(); + config.clone_to = "/tmp/hu-ensure-clone-fail-XXXX".into(); + let _ = std::fs::remove_dir_all(&config.clone_to); + let shell = FakeShell::new(); + shell.expect( + "gh", + &[ + "repo", + "clone", + "aladac/dotfiles", + "/tmp/hu-ensure-clone-fail-XXXX", + ], + "", + 1, + ); + let row = ensure_clone(&shell, &config).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("gh clone failed")); + } + + #[tokio::test] + async fn stow_apply_marks_installed_on_success() { + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", "/clone", "-t", "/home/u", "zsh"], + "", + 0, + ); + let row = stow_apply(&shell, "/clone", "/home/u", "zsh").await; + assert_eq!(row.status, Status::Installed); + assert!(row.note.contains("stowed")); + } + + #[tokio::test] + async fn stow_apply_marks_failed_with_conflict_summary() { + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", "/clone", "-t", "/home/u", "zsh"], + "", + 1, + ); + // FakeShell always returns empty stderr on expect; stuff a conflict via raw stdin + // — we test parse_stow_conflicts directly below to cover the parsing branch. + let row = stow_apply(&shell, "/clone", "/home/u", "zsh").await; + assert_eq!(row.status, Status::Failed); + } + + #[test] + fn parse_stow_conflicts_summarizes_one_conflict() { + let stderr = "existing target is not owned by stow: .zshrc\n"; + assert_eq!(parse_stow_conflicts(stderr), stderr.trim()); + } + + #[test] + fn parse_stow_conflicts_counts_extras() { + let stderr = "\ +existing target is not owned by stow: .zshrc +existing target is not owned by stow: .gitconfig +existing target is not owned by stow: .vimrc +"; + let summary = parse_stow_conflicts(stderr); + assert!(summary.contains(".zshrc")); + assert!(summary.contains("+2 more")); + } + + #[test] + fn parse_stow_conflicts_falls_back_when_no_keyword() { + let stderr = "stow: command not found\n"; + let summary = parse_stow_conflicts(stderr); + assert!(summary.starts_with("stow failed:")); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index d6bec51..db9569b 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -9,6 +9,7 @@ mod bootstrap; mod cli; mod config; mod display; +mod dotfiles; mod os; mod packages; mod pkgs; From 9c1db96d96cec39dfd5e621b506b1097ffa1bfea Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:57:12 +0200 Subject: [PATCH 11/15] feat(setup): wire `hu setup dotfiles` end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/dotfiles.rs | 86 +++++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 25 +++++++++++-- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/setup/dotfiles.rs b/src/setup/dotfiles.rs index 08f70ad..884aa28 100644 --- a/src/setup/dotfiles.rs +++ b/src/setup/dotfiles.rs @@ -100,6 +100,34 @@ pub async fn stow_apply( } } +/// Orchestrate the full dotfiles phase: clone (or skip) → stow each package. +/// +/// Returns one `StatusRow` per step. If the clone fails the per-package +/// stow rows are skipped — there's nothing to stow from. +pub async fn run(shell: &S, config: &DotfilesConfig) -> Vec { + let mut rows = Vec::with_capacity(1 + config.packages.len()); + let clone_row = ensure_clone(shell, config).await; + let clone_satisfied = clone_row.status.is_satisfied(); + rows.push(clone_row); + if !clone_satisfied { + for pkg in &config.packages { + rows.push( + StatusRow::new("stow", pkg, Status::Skipped) + .with_note("clone failed — nothing to stow"), + ); + } + return rows; + } + let target = expand_tilde("~/").to_string_lossy().into_owned(); + let clone_to = expand_tilde(&config.clone_to) + .to_string_lossy() + .into_owned(); + for pkg in &config.packages { + rows.push(stow_apply(shell, &clone_to, &target, pkg).await); + } + rows +} + /// Extract a concise conflict summary from stow stderr. /// /// stow's stderr lists conflicting files line by line. We keep the gist @@ -263,4 +291,62 @@ existing target is not owned by stow: .vimrc let summary = parse_stow_conflicts(stderr); assert!(summary.starts_with("stow failed:")); } + + #[tokio::test] + async fn run_skips_stow_when_clone_fails() { + let mut config = dotfiles_config(); + config.clone_to = "/tmp/hu-run-clone-fails".into(); + let _ = std::fs::remove_dir_all(&config.clone_to); + let shell = FakeShell::new(); + shell.expect( + "gh", + &[ + "repo", + "clone", + "aladac/dotfiles", + "/tmp/hu-run-clone-fails", + ], + "", + 1, + ); + let rows = run(&shell, &config).await; + assert_eq!(rows.len(), 1 + config.packages.len()); + assert_eq!(rows[0].status, Status::Failed); + for stow_row in &rows[1..] { + assert_eq!(stow_row.status, Status::Skipped); + assert!(stow_row.note.contains("clone failed")); + } + } + + #[tokio::test] + async fn run_stows_each_configured_package_when_clone_present() { + let temp = std::env::temp_dir().join("hu-run-clone-present"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + let mut config = dotfiles_config(); + config.clone_to = temp.to_string_lossy().into_owned(); + config.packages = vec!["zsh".into(), "kitty".into()]; + + let target = expand_tilde("~/").to_string_lossy().into_owned(); + let clone_to = temp.to_string_lossy().into_owned(); + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", &clone_to, "-t", &target, "zsh"], + "", + 0, + ); + shell.expect( + "stow", + &["-R", "-d", &clone_to, "-t", &target, "kitty"], + "", + 0, + ); + let rows = run(&shell, &config).await; + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].status, Status::Already); + assert_eq!(rows[1].status, Status::Installed); + assert_eq!(rows[2].status, Status::Installed); + std::fs::remove_dir_all(&temp).unwrap(); + } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index db9569b..4893a80 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -34,9 +34,7 @@ pub async fn run_command(cmd: SetupCommand) -> Result<()> { bail!("hu setup run: not yet implemented (Phase 5)"); } SetupCommand::Pkgs(args) => run_pkgs(args).await, - SetupCommand::Dotfiles => { - bail!("hu setup dotfiles: not yet implemented (Phase 3)"); - } + SetupCommand::Dotfiles => run_dotfiles().await, SetupCommand::Ssh => { bail!("hu setup ssh: not yet implemented (Phase 4)"); } @@ -84,6 +82,27 @@ async fn run_status() -> Result<()> { Ok(()) } +async fn run_dotfiles() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + println!("{} host: {}", "◆".cyan(), os.label()); + println!( + "{} dotfiles: {} → {}", + "◆".cyan(), + cfg.dotfiles.repo, + cfg.dotfiles.clone_to + ); + let rows = dotfiles::run(&shell, &cfg.dotfiles).await; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("dotfiles phase had failures — see table above"); + } + Ok(()) +} + async fn run_pkgs(args: cli::PkgsArgs) -> Result<()> { let os = Os::detect()?; let cfg = config::load().context("load setup.toml")?; From bc150a47de87927761e35e7f85da9aef5a3190ff Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 19:59:12 +0200 Subject: [PATCH 12/15] feat(setup): add OpClient trait + RealOp/MockOp + key spec primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/setup/mod.rs | 1 + src/setup/ssh.rs | 333 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 src/setup/ssh.rs diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 4893a80..63a7602 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -13,6 +13,7 @@ mod dotfiles; mod os; mod packages; mod pkgs; +mod ssh; mod status; mod types; diff --git a/src/setup/ssh.rs b/src/setup/ssh.rs new file mode 100644 index 0000000..2d2d97b --- /dev/null +++ b/src/setup/ssh.rs @@ -0,0 +1,333 @@ +//! SSH phase — read keys from 1Password and place them on disk. +//! +//! `OpClient` is the second earned trait abstraction (per doctrine §1: ≥2 +//! implementers — `RealOp` over the `op` CLI + `MockOp` for tests; painful +//! I/O to fake otherwise — vault auth, account state). +//! +//! Key writing is split into a pure `KeySpec` description (path / mode / +//! content) and the I/O glue that applies it. Tests cover the spec +//! computation; `fs::write` + `chmod` are exercised at run-time. + +// reason: ssh phase wired by `hu setup ssh` (chunk 4.2) and `setup run` +// (Phase 5). Tests cover the surface now. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use crate::util::shell::Shell; + +/// Standard chmod for SSH private keys. +pub const PRIVATE_KEY_MODE: u32 = 0o600; +/// Standard chmod for SSH public keys. +pub const PUBLIC_KEY_MODE: u32 = 0o644; + +/// 1Password CLI client. +#[async_trait] +pub trait OpClient: Send + Sync { + /// Read a single field via `op read `. Refs are formatted as + /// `op:////`. + async fn read(&self, op_ref: &str) -> Result; + + /// Whether the host has at least one signed-in 1Password account. + /// Used to short-circuit the SSH phase with a clear error rather than + /// emitting cryptic `op` failures per item. + async fn account_status(&self) -> Result; +} + +/// Real `op` CLI wrapper backed by the [`Shell`] chokepoint. +pub struct RealOp<'s, S: Shell + ?Sized> { + shell: &'s S, +} + +impl<'s, S: Shell + ?Sized> RealOp<'s, S> { + pub fn new(shell: &'s S) -> Self { + Self { shell } + } +} + +#[async_trait] +impl OpClient for RealOp<'_, S> { + async fn read(&self, op_ref: &str) -> Result { + let out = self + .shell + .run("op", &["read", op_ref]) + .await + .with_context(|| format!("op read {}", op_ref))?; + if !out.is_success() { + anyhow::bail!( + "op read {} failed (exit {:?}): {}", + op_ref, + out.status.code(), + out.stderr.trim() + ); + } + Ok(out.stdout) + } + + async fn account_status(&self) -> Result { + let out = self.shell.run("op", &["account", "list"]).await?; + Ok(out.is_success() && !out.stdout.trim().is_empty()) + } +} + +/// Specification for one SSH key file to write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeySpec { + pub path: PathBuf, + pub mode: u32, + pub content: String, +} + +/// Build the (private, public) `KeySpec` pair for one configured op item. +/// +/// `op_item_ref` is the vault-relative path like `"SSH/id_ed25519"`. The +/// basename (`id_ed25519`) becomes the key file name in `key_dir`. +pub fn key_specs_for_item( + key_dir: &Path, + op_item_ref: &str, + private_content: String, + public_content: String, +) -> Vec { + let basename = op_item_ref + .rsplit('/') + .next() + .unwrap_or(op_item_ref) + .to_string(); + vec![ + KeySpec { + path: key_dir.join(&basename), + mode: PRIVATE_KEY_MODE, + content: ensure_trailing_newline(&private_content), + }, + KeySpec { + path: key_dir.join(format!("{}.pub", basename)), + mode: PUBLIC_KEY_MODE, + content: ensure_trailing_newline(&public_content), + }, + ] +} + +fn ensure_trailing_newline(s: &str) -> String { + if s.ends_with('\n') { + s.to_string() + } else { + format!("{}\n", s) + } +} + +/// Build the full op `read` reference: `op:////`. +pub fn op_ref(vault: &str, item: &str, field: &str) -> String { + format!("op://{}/{}/{}", vault, item, field) +} + +/// Fetch the private + public key pair for one item. +pub async fn fetch_key_pair( + op: &O, + vault: &str, + item: &str, +) -> Result<(String, String)> { + let private = op + .read(&op_ref(vault, item, "private_key")) + .await + .with_context(|| format!("read private key for {}/{}", vault, item))?; + let public = op + .read(&op_ref(vault, item, "public_key")) + .await + .with_context(|| format!("read public key for {}/{}", vault, item))?; + Ok((private, public)) +} + +#[cfg(test)] +mod fake { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Scripted op client for tests. Maps op refs → scripted output. + pub struct MockOp { + responses: Mutex>, + signed_in: Mutex, + reads: Mutex>, + } + + impl MockOp { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + signed_in: Mutex::new(true), + reads: Mutex::new(Vec::new()), + } + } + + pub fn expect(&self, op_ref: &str, content: &str) { + self.responses + .lock() + .expect("mock op mutex") + .insert(op_ref.to_string(), content.to_string()); + } + + pub fn set_signed_in(&self, ok: bool) { + *self.signed_in.lock().expect("mock op mutex") = ok; + } + + pub fn reads(&self) -> Vec { + self.reads.lock().expect("mock op mutex").clone() + } + } + + #[async_trait] + impl OpClient for MockOp { + async fn read(&self, op_ref: &str) -> Result { + self.reads + .lock() + .expect("mock op mutex") + .push(op_ref.to_string()); + let map = self.responses.lock().expect("mock op mutex"); + match map.get(op_ref) { + Some(content) => Ok(content.clone()), + None => anyhow::bail!("MockOp: no response registered for {}", op_ref), + } + } + + async fn account_status(&self) -> Result { + Ok(*self.signed_in.lock().expect("mock op mutex")) + } + } +} + +#[cfg(test)] +pub use fake::MockOp; + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn op_ref_formats_correctly() { + assert_eq!( + op_ref("Personal", "SSH/id_ed25519", "private_key"), + "op://Personal/SSH/id_ed25519/private_key" + ); + } + + #[test] + fn ensure_trailing_newline_appends_when_missing() { + assert_eq!(ensure_trailing_newline("abc"), "abc\n"); + assert_eq!(ensure_trailing_newline("abc\n"), "abc\n"); + assert_eq!(ensure_trailing_newline(""), "\n"); + } + + #[test] + fn key_specs_extract_basename_from_path() { + let specs = key_specs_for_item( + Path::new("/home/u/.ssh"), + "SSH/id_ed25519", + "PRIV".into(), + "PUB".into(), + ); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].path, PathBuf::from("/home/u/.ssh/id_ed25519")); + assert_eq!(specs[1].path, PathBuf::from("/home/u/.ssh/id_ed25519.pub")); + } + + #[test] + fn key_specs_apply_correct_modes() { + let specs = key_specs_for_item( + Path::new("/home/u/.ssh"), + "id_rsa", + "priv".into(), + "pub".into(), + ); + assert_eq!(specs[0].mode, 0o600); + assert_eq!(specs[1].mode, 0o644); + } + + #[test] + fn key_specs_normalize_trailing_newline() { + let specs = key_specs_for_item(Path::new("/x"), "key", "PRIV-NO-NL".into(), "PUB\n".into()); + assert_eq!(specs[0].content, "PRIV-NO-NL\n"); + assert_eq!(specs[1].content, "PUB\n"); + } + + #[tokio::test] + async fn mock_op_returns_scripted_content() { + let op = MockOp::new(); + op.expect("op://V/I/private_key", "PRIVATE-CONTENT"); + let v = op.read("op://V/I/private_key").await.unwrap(); + assert_eq!(v, "PRIVATE-CONTENT"); + } + + #[tokio::test] + async fn mock_op_errors_on_unscripted_read() { + let op = MockOp::new(); + let err = op.read("op://X/Y/z").await.unwrap_err(); + assert!(err.to_string().contains("no response registered")); + } + + #[tokio::test] + async fn mock_op_account_status_default_true() { + let op = MockOp::new(); + assert!(op.account_status().await.unwrap()); + op.set_signed_in(false); + assert!(!op.account_status().await.unwrap()); + } + + #[tokio::test] + async fn fetch_key_pair_reads_both_fields() { + let op = MockOp::new(); + op.expect("op://Personal/SSH/id_ed25519/private_key", "PRIV"); + op.expect("op://Personal/SSH/id_ed25519/public_key", "PUB"); + let (priv_k, pub_k) = fetch_key_pair(&op, "Personal", "SSH/id_ed25519") + .await + .unwrap(); + assert_eq!(priv_k, "PRIV"); + assert_eq!(pub_k, "PUB"); + } + + #[tokio::test] + async fn fetch_key_pair_propagates_op_error() { + let op = MockOp::new(); + // private_key registered but public_key missing + op.expect("op://V/I/private_key", "PRIV"); + let err = fetch_key_pair(&op, "V", "I").await.unwrap_err(); + assert!(err.to_string().contains("public key")); + } + + #[tokio::test] + async fn real_op_passes_ref_through_shell() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect("op", &["read", "op://V/I/f"], "secret\n", 0); + let op = RealOp::new(&shell); + let v = op.read("op://V/I/f").await.unwrap(); + assert_eq!(v, "secret\n"); + } + + #[tokio::test] + async fn real_op_errors_on_nonzero_exit() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect("op", &["read", "op://V/I/f"], "", 1); + let op = RealOp::new(&shell); + let err = op.read("op://V/I/f").await.unwrap_err(); + assert!(err.to_string().contains("op read")); + } + + #[tokio::test] + async fn real_op_account_status_uses_account_list() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect( + "op", + &["account", "list"], + "URL EMAIL USER ID\nmy.1password.com me@x.com ABC\n", + 0, + ); + let op = RealOp::new(&shell); + assert!(op.account_status().await.unwrap()); + } +} From e77efb8433b0a4740068b3aafbd5c3e1e0b683cd Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 20:01:03 +0200 Subject: [PATCH 13/15] feat(setup): wire `hu setup ssh` end-to-end with idempotent key writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/mod.rs | 26 +++- src/setup/ssh.rs | 307 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 3 deletions(-) diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 63a7602..f13bce2 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -36,9 +36,7 @@ pub async fn run_command(cmd: SetupCommand) -> Result<()> { } SetupCommand::Pkgs(args) => run_pkgs(args).await, SetupCommand::Dotfiles => run_dotfiles().await, - SetupCommand::Ssh => { - bail!("hu setup ssh: not yet implemented (Phase 4)"); - } + SetupCommand::Ssh => run_ssh().await, SetupCommand::Config { cmd } => run_config(cmd).await, } } @@ -83,6 +81,28 @@ async fn run_status() -> Result<()> { Ok(()) } +async fn run_ssh() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + let op = ssh::RealOp::new(&shell); + println!("{} host: {}", "◆".cyan(), os.label()); + println!( + "{} ssh: vault={} items={}", + "◆".cyan(), + cfg.ssh.op_vault, + cfg.ssh.op_items.len() + ); + let rows = ssh::run(&op, &cfg.ssh).await; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("ssh phase had failures — see table above"); + } + Ok(()) +} + async fn run_dotfiles() -> Result<()> { let os = Os::detect()?; let cfg = config::load().context("load setup.toml")?; diff --git a/src/setup/ssh.rs b/src/setup/ssh.rs index 2d2d97b..e99dfdb 100644 --- a/src/setup/ssh.rs +++ b/src/setup/ssh.rs @@ -123,6 +123,70 @@ pub fn op_ref(vault: &str, item: &str, field: &str) -> String { format!("op://{}/{}/{}", vault, item, field) } +/// Decide how to apply a key spec given the current filesystem state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecAction { + /// File exists with matching content + correct mode → nothing to do. + AlreadyMatches, + /// File missing or content differs — write it (idempotent overwrite). + WriteFile, +} + +/// Inspect the filesystem and decide what action a `KeySpec` needs. +/// +/// This is pure-ish — only reads. Tests cover the matrix without mocking. +pub fn classify_spec(spec: &KeySpec) -> SpecAction { + if !spec.path.exists() { + return SpecAction::WriteFile; + } + let existing = match std::fs::read_to_string(&spec.path) { + Ok(s) => s, + Err(_) => return SpecAction::WriteFile, + }; + if existing != spec.content { + return SpecAction::WriteFile; + } + if let Ok(meta) = std::fs::metadata(&spec.path) { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o777 != spec.mode { + return SpecAction::WriteFile; + } + } + SpecAction::AlreadyMatches +} + +/// Apply one key spec to disk. Idempotent: returns `Already` when the file +/// already matches; writes + chmods otherwise; re-reads to verify mode. +pub fn apply_spec(spec: &KeySpec) -> Result { + use std::os::unix::fs::PermissionsExt; + if classify_spec(spec) == SpecAction::AlreadyMatches { + return Ok(crate::setup::types::Status::Already); + } + if let Some(parent) = spec.path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir {}", parent.display()))?; + } + std::fs::write(&spec.path, &spec.content) + .with_context(|| format!("write {}", spec.path.display()))?; + let mut perms = std::fs::metadata(&spec.path) + .with_context(|| format!("stat {}", spec.path.display()))? + .permissions(); + perms.set_mode(spec.mode); + std::fs::set_permissions(&spec.path, perms) + .with_context(|| format!("chmod {}", spec.path.display()))?; + // Re-verify mode landed. + let final_mode = std::fs::metadata(&spec.path)?.permissions().mode() & 0o777; + if final_mode != spec.mode { + anyhow::bail!( + "post-write mode mismatch on {}: got {:o}, want {:o}", + spec.path.display(), + final_mode, + spec.mode + ); + } + Ok(crate::setup::types::Status::Installed) +} + /// Fetch the private + public key pair for one item. pub async fn fetch_key_pair( op: &O, @@ -140,6 +204,76 @@ pub async fn fetch_key_pair( Ok((private, public)) } +/// Orchestrate the full SSH phase: account check → for each item fetch +/// (private, public) → write specs. +pub async fn run( + op: &O, + config: &crate::setup::config::SshConfig, +) -> Vec { + use crate::setup::display::StatusRow; + use crate::setup::types::Status; + + let mut rows = Vec::new(); + + let signed_in = match op.account_status().await { + Ok(b) => b, + Err(e) => { + rows.push( + StatusRow::new("ssh", "op-account", Status::Failed) + .with_note(&format!("account_status errored: {}", e)), + ); + return rows; + } + }; + if !signed_in { + rows.push( + StatusRow::new("ssh", "op-account", Status::Failed) + .with_note("no signed-in 1Password account — run `op account add`"), + ); + return rows; + } + rows.push(StatusRow::new("ssh", "op-account", Status::Already).with_note("signed in")); + + let key_dir = expand_tilde(&config.key_dir); + for item in &config.op_items { + match fetch_key_pair(op, &config.op_vault, item).await { + Err(e) => { + rows.push( + StatusRow::new("ssh", item, Status::Failed) + .with_note(&format!("fetch failed: {}", e)), + ); + continue; + } + Ok((priv_k, pub_k)) => { + let specs = key_specs_for_item(&key_dir, item, priv_k, pub_k); + for spec in specs { + let basename = spec + .path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("?") + .to_string(); + match apply_spec(&spec) { + Ok(status) => rows.push( + StatusRow::new("ssh", &basename, status) + .with_note(&format!("mode {:o}", spec.mode)), + ), + Err(e) => rows.push( + StatusRow::new("ssh", &basename, Status::Failed) + .with_note(&format!("apply failed: {}", e)), + ), + } + } + } + } + } + rows +} + +fn expand_tilde(raw: &str) -> std::path::PathBuf { + crate::setup::dotfiles::expand_tilde(raw) +} + #[cfg(test)] mod fake { use super::*; @@ -317,6 +451,179 @@ mod tests { assert!(err.to_string().contains("op read")); } + #[test] + fn classify_spec_returns_write_when_missing() { + let spec = KeySpec { + path: PathBuf::from("/nonexistent/path/file"), + mode: 0o600, + content: "x".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + } + + #[test] + fn classify_spec_returns_already_when_match() { + let dir = std::env::temp_dir().join("hu-classify-already"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::AlreadyMatches); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn classify_spec_returns_write_when_content_differs() { + let dir = std::env::temp_dir().join("hu-classify-content-diff"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "old\n").unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "new\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn classify_spec_returns_write_when_mode_differs() { + let dir = std::env::temp_dir().join("hu-classify-mode-diff"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o644); // different from spec + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn apply_spec_writes_file_with_correct_mode() { + let dir = std::env::temp_dir().join("hu-apply-spec-write"); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("id_test"); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "PRIVATE\n".into(), + }; + let status = apply_spec(&spec).unwrap(); + assert_eq!(status, crate::setup::types::Status::Installed); + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content, "PRIVATE\n"); + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn apply_spec_skips_when_already_matches() { + let dir = std::env::temp_dir().join("hu-apply-spec-already"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("id_test"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + let status = apply_spec(&spec).unwrap(); + assert_eq!(status, crate::setup::types::Status::Already); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn ssh_run_fails_when_not_signed_in() { + let op = MockOp::new(); + op.set_signed_in(false); + let cfg = crate::setup::config::SshConfig::default(); + let rows = run(&op, &cfg).await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, crate::setup::types::Status::Failed); + assert!(rows[0].note.contains("op account add")); + } + + #[tokio::test] + async fn ssh_run_writes_keys_when_signed_in() { + let dir = std::env::temp_dir().join("hu-ssh-run-keys"); + let _ = std::fs::remove_dir_all(&dir); + let op = MockOp::new(); + op.expect("op://Personal/SSH/id_test/private_key", "PRIV-CONTENT"); + op.expect("op://Personal/SSH/id_test/public_key", "PUB-CONTENT"); + let cfg = crate::setup::config::SshConfig { + op_vault: "Personal".into(), + op_items: vec!["SSH/id_test".into()], + key_dir: dir.to_string_lossy().into_owned(), + }; + let rows = run(&op, &cfg).await; + // 1 op-account + 2 key files + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].name, "op-account"); + assert_eq!(rows[1].name, "id_test"); + assert_eq!(rows[2].name, "id_test.pub"); + assert_eq!(rows[1].status, crate::setup::types::Status::Installed); + assert_eq!(rows[2].status, crate::setup::types::Status::Installed); + // Verify perms landed + use std::os::unix::fs::PermissionsExt; + let priv_mode = std::fs::metadata(dir.join("id_test")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(priv_mode, 0o600); + let pub_mode = std::fs::metadata(dir.join("id_test.pub")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(pub_mode, 0o644); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn ssh_run_marks_item_failed_when_op_read_errors() { + let dir = std::env::temp_dir().join("hu-ssh-run-op-fail"); + let _ = std::fs::remove_dir_all(&dir); + let op = MockOp::new(); + // private_key missing → fetch fails + let cfg = crate::setup::config::SshConfig { + op_vault: "V".into(), + op_items: vec!["I".into()], + key_dir: dir.to_string_lossy().into_owned(), + }; + let rows = run(&op, &cfg).await; + assert_eq!(rows.len(), 2); // op-account + 1 item failure + let item_row = &rows[1]; + assert_eq!(item_row.status, crate::setup::types::Status::Failed); + assert!(item_row.note.contains("fetch failed")); + } + #[tokio::test] async fn real_op_account_status_uses_account_list() { use crate::util::shell::FakeShell; From 769eacc9265b24c001269bb3b503533b31402288 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 20:02:52 +0200 Subject: [PATCH 14/15] =?UTF-8?q?feat(setup):=20wire=20`hu=20setup=20run`?= =?UTF-8?q?=20=E2=80=94=20full=20host=20bootstrap=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.].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. --- src/setup/mod.rs | 29 ++++- src/setup/run.rs | 296 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/setup/run.rs diff --git a/src/setup/mod.rs b/src/setup/mod.rs index f13bce2..789aa93 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -13,6 +13,7 @@ mod dotfiles; mod os; mod packages; mod pkgs; +mod run; mod ssh; mod status; mod types; @@ -31,9 +32,7 @@ use crate::util::shell::RealShell; pub async fn run_command(cmd: SetupCommand) -> Result<()> { match cmd { SetupCommand::Status | SetupCommand::Preview => run_status().await, - SetupCommand::Run(_) => { - bail!("hu setup run: not yet implemented (Phase 5)"); - } + SetupCommand::Run(args) => run_full(args).await, SetupCommand::Pkgs(args) => run_pkgs(args).await, SetupCommand::Dotfiles => run_dotfiles().await, SetupCommand::Ssh => run_ssh().await, @@ -81,6 +80,30 @@ async fn run_status() -> Result<()> { Ok(()) } +async fn run_full(args: cli::RunArgs) -> Result<()> { + let os = Os::detect()?; + let cfg_base = config::load().context("load setup.toml")?; + let hostname = run::current_hostname(); + let cfg = run::apply_host_overrides(cfg_base, &hostname); + let shell = RealShell; + let op = ssh::RealOp::new(&shell); + println!("{} host: {} ({})", "◆".cyan(), os.label(), hostname); + if args.dry_run { + println!("{} dry-run — no changes will be made", "◐".yellow()); + } + if let Some(phase) = &args.only { + println!("{} only: {:?}", "◆".cyan(), phase); + } + let rows = run::run_full(&shell, &op, &cfg, &args, &os).await?; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("setup run had failures — see table above"); + } + Ok(()) +} + async fn run_ssh() -> Result<()> { let os = Os::detect()?; let cfg = config::load().context("load setup.toml")?; diff --git a/src/setup/run.rs b/src/setup/run.rs new file mode 100644 index 0000000..9082828 --- /dev/null +++ b/src/setup/run.rs @@ -0,0 +1,296 @@ +//! `hu setup run` — full host bootstrap orchestrator. +//! +//! Walks the three phases (packages, dotfiles, ssh) in order, honoring +//! `--only` filtering, `--dry-run` short-circuit, and per-host overrides +//! from `[host.]` blocks in setup.toml. Returns the aggregated +//! `Vec` for display at the call site. + +// reason: orchestrator wired by `hu setup run` (this chunk). Tests cover +// the surface; only-filter + host-override logic is unit-tested without I/O. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::cli::{PkgsArgs, RunArgs, RunPhase}; +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::os::Os; +use crate::setup::ssh::OpClient; +use crate::setup::{dotfiles, pkgs, ssh}; +use crate::util::shell::Shell; + +/// Resolve the active host name for `[host.]` overrides. +/// +/// Reads `$HOSTNAME` first (settable for tests), then falls back to +/// `hostname::get()` if needed. Returns `"unknown"` on lookup failure. +pub fn current_hostname() -> String { + if let Ok(h) = std::env::var("HOSTNAME") { + if !h.is_empty() { + return h; + } + } + // Fall back to the platform call. + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|out| { + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + None + } + }) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Apply per-host overrides to a base config. +/// +/// Currently merges `brew_extra` and `mise_extra` from the matching +/// `[host.]` block onto the base package lists. +pub fn apply_host_overrides(mut config: SetupConfig, hostname: &str) -> SetupConfig { + if let Some(over) = config.host.get(hostname).cloned() { + for pkg in over.brew_extra { + if !config.packages.brew.contains(&pkg) { + config.packages.brew.push(pkg); + } + } + for pkg in over.mise_extra { + if !config.packages.mise.contains(&pkg) { + config.packages.mise.push(pkg); + } + } + } + config +} + +/// Full orchestrator. Walks phases in order: packages → dotfiles → ssh. +/// +/// Phase selection follows `args.only`: +/// - `None` → all three phases run +/// - `Some(Pkgs)` → only packages +/// - `Some(Dotfiles)` → only dotfiles +/// - `Some(Ssh)` → only ssh +pub async fn run_full( + shell: &S, + op: &O, + config: &SetupConfig, + args: &RunArgs, + os: &Os, +) -> Result> { + let mut rows = Vec::new(); + + if should_run_phase(&args.only, RunPhase::Pkgs) { + let pkgs_args = PkgsArgs { + only: vec![], + dry_run: args.dry_run, + }; + let pkg_rows = pkgs::run(shell, config, &pkgs_args, os).await?; + rows.extend(pkg_rows); + } + + if should_run_phase(&args.only, RunPhase::Dotfiles) { + if args.dry_run { + for pkg in &config.dotfiles.packages { + rows.push( + crate::setup::display::StatusRow::new( + "stow", + pkg, + crate::setup::types::Status::Skipped, + ) + .with_note("dry-run"), + ); + } + } else { + let df_rows = dotfiles::run(shell, &config.dotfiles).await; + rows.extend(df_rows); + } + } + + if should_run_phase(&args.only, RunPhase::Ssh) { + if args.dry_run { + for item in &config.ssh.op_items { + rows.push( + crate::setup::display::StatusRow::new( + "ssh", + item, + crate::setup::types::Status::Skipped, + ) + .with_note("dry-run"), + ); + } + } else { + let ssh_rows = ssh::run(op, &config.ssh).await; + rows.extend(ssh_rows); + } + } + + Ok(rows) +} + +fn should_run_phase(only: &Option, phase: RunPhase) -> bool { + match only { + None => true, + Some(p) => *p == phase, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::cli::{RunArgs, RunPhase}; + use crate::setup::config::HostOverride; + use crate::setup::ssh::MockOp; + use crate::util::shell::FakeShell; + + fn run_args(only: Option, dry_run: bool, yes: bool) -> RunArgs { + RunArgs { only, dry_run, yes } + } + + #[test] + fn host_override_appends_brew_extra() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.host.insert( + "marauder".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg, "marauder"); + assert!(merged.packages.brew.contains(&"gh".to_string())); + assert!(merged.packages.brew.contains(&"nvtop".to_string())); + } + + #[test] + fn host_override_dedupes_existing() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into(), "nvtop".into()]; + cfg.host.insert( + "marauder".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg, "marauder"); + let count = merged + .packages + .brew + .iter() + .filter(|p| *p == "nvtop") + .count(); + assert_eq!(count, 1); + } + + #[test] + fn host_override_no_match_returns_unchanged() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.host.insert( + "other".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg.clone(), "marauder"); + assert_eq!(merged.packages.brew, cfg.packages.brew); + } + + #[test] + fn host_override_appends_mise_extra() { + let mut cfg = SetupConfig::default(); + cfg.packages.mise = vec!["node@lts".into()]; + cfg.host.insert( + "h".into(), + HostOverride { + brew_extra: vec![], + mise_extra: vec!["go@latest".into()], + }, + ); + let merged = apply_host_overrides(cfg, "h"); + assert!(merged.packages.mise.contains(&"go@latest".to_string())); + } + + #[test] + fn should_run_phase_returns_true_when_no_filter() { + assert!(should_run_phase(&None, RunPhase::Pkgs)); + assert!(should_run_phase(&None, RunPhase::Dotfiles)); + assert!(should_run_phase(&None, RunPhase::Ssh)); + } + + #[test] + fn should_run_phase_filters_to_one() { + assert!(should_run_phase(&Some(RunPhase::Ssh), RunPhase::Ssh)); + assert!(!should_run_phase(&Some(RunPhase::Ssh), RunPhase::Pkgs)); + assert!(!should_run_phase(&Some(RunPhase::Ssh), RunPhase::Dotfiles)); + } + + #[tokio::test] + async fn run_full_dry_run_emits_all_phases_skipped() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + // Trim defaults to keep test compact + cfg.packages.brew = vec!["gh".into()]; + cfg.packages.mise = vec!["node@lts".into()]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full(&shell, &op, &cfg, &run_args(None, true, false), &Os::Mac) + .await + .unwrap(); + // bootstrap + 1 brew + 1 mise + 1 stow + 1 ssh = 5 + assert_eq!(rows.len(), 5); + for r in &rows { + assert_eq!(r.status, crate::setup::types::Status::Skipped); + } + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn run_full_only_pkgs_skips_other_phases() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.packages.mise = vec![]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full( + &shell, + &op, + &cfg, + &run_args(Some(RunPhase::Pkgs), true, false), + &Os::Mac, + ) + .await + .unwrap(); + // bootstrap + 1 brew = 2 rows; no stow/ssh + assert!(!rows.iter().any(|r| r.category == "stow")); + assert!(!rows.iter().any(|r| r.category == "ssh")); + } + + #[tokio::test] + async fn run_full_only_ssh_skips_pkgs_and_dotfiles() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full( + &shell, + &op, + &cfg, + &run_args(Some(RunPhase::Ssh), true, false), + &Os::Mac, + ) + .await + .unwrap(); + assert!(!rows.iter().any(|r| r.category == "brew")); + assert!(!rows.iter().any(|r| r.category == "stow")); + assert!(rows.iter().any(|r| r.category == "ssh")); + } +} From 6b795e3de79252fe510d91edef3f812050dea739 Mon Sep 17 00:00:00 2001 From: aladac Date: Fri, 8 May 2026 20:05:33 +0200 Subject: [PATCH 15/15] feat(setup): add Phase 6 post-install checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/setup/mod.rs | 1 + src/setup/post.rs | 260 ++++++++++++++++++++++++++++++++++++++++++++++ src/setup/run.rs | 9 +- 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/setup/post.rs diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 789aa93..fb2e473 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -13,6 +13,7 @@ mod dotfiles; mod os; mod packages; mod pkgs; +mod post; mod run; mod ssh; mod status; diff --git a/src/setup/post.rs b/src/setup/post.rs new file mode 100644 index 0000000..da51293 --- /dev/null +++ b/src/setup/post.rs @@ -0,0 +1,260 @@ +//! Phase 6 post-install checks. +//! +//! Runs after `pkgs → dotfiles → ssh` to verify the host is in a usable +//! state and start any services that need explicit kick. Each check +//! produces a `StatusRow`; nothing here is destructive. + +// reason: post checks called from `hu setup run` orchestrator. +#![allow(dead_code)] + +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Run all post-install checks. Currently: +/// - `brew services start postgresql` (only if configured + brew has it) +/// - `gh auth status` — read-only +/// - `ssh -T git@github.com` — smoke (exit 1 with greeting = success) +pub async fn run(shell: &S, config: &SetupConfig) -> Vec { + let mut rows = Vec::new(); + + if config.packages.brew.iter().any(|p| p == "postgresql") { + rows.push(start_postgres(shell).await); + } + rows.push(check_gh_auth(shell).await); + rows.push(smoke_github_ssh(shell).await); + + rows +} + +async fn start_postgres(shell: &S) -> StatusRow { + let out = shell + .run("brew", &["services", "info", "postgresql", "--json"]) + .await; + let already_running = match out { + Ok(out) if out.is_success() => { + out.stdout.contains("\"running\":true") || out.stdout.contains("\"running\": true") + } + _ => false, + }; + if already_running { + return StatusRow::new("post", "postgresql", Status::Already) + .with_note("brew service already running"); + } + match shell + .run("brew", &["services", "start", "postgresql"]) + .await + { + Ok(out) if out.is_success() => StatusRow::new("post", "postgresql", Status::Installed) + .with_note("brew service started"), + Ok(out) => StatusRow::new("post", "postgresql", Status::Failed).with_note(&format!( + "brew services start failed (exit {:?}): {}", + out.status.code(), + out.stderr.trim() + )), + Err(e) => StatusRow::new("post", "postgresql", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +async fn check_gh_auth(shell: &S) -> StatusRow { + match shell.run("gh", &["auth", "status"]).await { + Ok(out) if out.is_success() => { + StatusRow::new("post", "gh-auth", Status::Already).with_note("gh signed in") + } + Ok(_) => StatusRow::new("post", "gh-auth", Status::Failed) + .with_note("not signed in — run `gh auth login`"), + Err(e) => StatusRow::new("post", "gh-auth", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +async fn smoke_github_ssh(shell: &S) -> StatusRow { + // `ssh -T git@github.com` exits 1 with a greeting on success — odd but + // documented. We grep stderr for "successfully authenticated" instead. + match shell + .run( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + ) + .await + { + Ok(out) => { + let combined = format!("{}{}", out.stdout, out.stderr); + if combined.contains("successfully authenticated") { + StatusRow::new("post", "github-ssh", Status::Already) + .with_note("ssh -T git@github.com authenticated") + } else { + StatusRow::new("post", "github-ssh", Status::Failed).with_note(&format!( + "no auth confirmation (exit {:?})", + out.status.code() + )) + } + } + Err(e) => StatusRow::new("post", "github-ssh", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn cfg_with_postgres(include: bool) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = if include { + vec!["gh".into(), "postgresql".into()] + } else { + vec!["gh".into()] + }; + cfg + } + + #[tokio::test] + async fn run_skips_postgres_when_not_configured() { + let shell = FakeShell::new(); + // gh + ssh expected; postgres should NOT be invoked + shell.expect("gh", &["auth", "status"], "Logged in to github.com\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Hi user! You've successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(false); + let rows = run(&shell, &cfg).await; + assert_eq!(rows.len(), 2); + assert!(!rows.iter().any(|r| r.name == "postgresql")); + } + + #[tokio::test] + async fn run_starts_postgres_when_not_running() { + let shell = FakeShell::new(); + shell.expect( + "brew", + &["services", "info", "postgresql", "--json"], + "[{\"running\":false}]", + 0, + ); + shell.expect( + "brew", + &["services", "start", "postgresql"], + "Started postgresql\n", + 0, + ); + shell.expect("gh", &["auth", "status"], "Logged in\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(true); + let rows = run(&shell, &cfg).await; + assert_eq!(rows.len(), 3); + let pg = rows.iter().find(|r| r.name == "postgresql").unwrap(); + assert_eq!(pg.status, Status::Installed); + } + + #[tokio::test] + async fn run_marks_postgres_already_when_running() { + let shell = FakeShell::new(); + shell.expect( + "brew", + &["services", "info", "postgresql", "--json"], + "[{\"running\":true,\"name\":\"postgresql\"}]", + 0, + ); + shell.expect("gh", &["auth", "status"], "Logged in\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(true); + let rows = run(&shell, &cfg).await; + let pg = rows.iter().find(|r| r.name == "postgresql").unwrap(); + assert_eq!(pg.status, Status::Already); + } + + #[tokio::test] + async fn check_gh_auth_marks_failed_when_not_signed_in() { + let shell = FakeShell::new(); + shell.expect("gh", &["auth", "status"], "", 1); + let row = check_gh_auth(&shell).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("gh auth login")); + } + + #[tokio::test] + async fn smoke_ssh_marks_already_when_greeting_present() { + let shell = FakeShell::new(); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Hi aladac! You've successfully authenticated.\n", + 1, + ); + let row = smoke_github_ssh(&shell).await; + assert_eq!(row.status, Status::Already); + } + + #[tokio::test] + async fn smoke_ssh_marks_failed_without_greeting() { + let shell = FakeShell::new(); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Permission denied", + 255, + ); + let row = smoke_github_ssh(&shell).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("no auth confirmation")); + } +} diff --git a/src/setup/run.rs b/src/setup/run.rs index 9082828..06d9ac1 100644 --- a/src/setup/run.rs +++ b/src/setup/run.rs @@ -16,7 +16,7 @@ use crate::setup::config::SetupConfig; use crate::setup::display::StatusRow; use crate::setup::os::Os; use crate::setup::ssh::OpClient; -use crate::setup::{dotfiles, pkgs, ssh}; +use crate::setup::{dotfiles, pkgs, post, ssh}; use crate::util::shell::Shell; /// Resolve the active host name for `[host.]` overrides. @@ -125,6 +125,13 @@ pub async fn run_full( } } + // Phase 6: post-install checks (only when running everything, not under + // a single-phase --only filter, and not for dry-run). + if args.only.is_none() && !args.dry_run { + let post_rows = post::run(shell, config).await; + rows.extend(post_rows); + } + Ok(rows) }