diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 9f8f016..b09f16d 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -18,3 +18,15 @@ path = "src/bin/gnome-export.rs" [[bin]] name = "secrets-setup" path = "src/bin/secrets-setup.rs" + +[[bin]] +name = "flake-bump" +path = "src/bin/flake-bump.rs" + +[[bin]] +name = "gen-diff" +path = "src/bin/gen-diff.rs" + +[[bin]] +name = "health-check" +path = "src/bin/health-check.rs" diff --git a/tools/flake.nix b/tools/flake.nix index 0f3c2e8..5589c95 100644 --- a/tools/flake.nix +++ b/tools/flake.nix @@ -21,9 +21,12 @@ apps = forAllSystems (system: let pkg = self.packages.${system}.default; in { - darwin-export = { type = "app"; program = "${pkg}/bin/darwin-export"; }; - gnome-export = { type = "app"; program = "${pkg}/bin/gnome-export"; }; - secrets-setup = { type = "app"; program = "${pkg}/bin/secrets-setup"; }; + darwin-export = { type = "app"; program = "${pkg}/bin/darwin-export"; }; + gnome-export = { type = "app"; program = "${pkg}/bin/gnome-export"; }; + secrets-setup = { type = "app"; program = "${pkg}/bin/secrets-setup"; }; + flake-bump = { type = "app"; program = "${pkg}/bin/flake-bump"; }; + gen-diff = { type = "app"; program = "${pkg}/bin/gen-diff"; }; + health-check = { type = "app"; program = "${pkg}/bin/health-check"; }; } ); }; diff --git a/tools/src/bin/flake-bump.rs b/tools/src/bin/flake-bump.rs new file mode 100644 index 0000000..e23683e --- /dev/null +++ b/tools/src/bin/flake-bump.rs @@ -0,0 +1,182 @@ +/// flake-bump β€” show how stale each flake input is, with selective update. +/// +/// Usage: +/// flake-bump show staleness table +/// flake-bump --update bump one input and commit flake.lock +/// flake-bump --update-all bump everything and commit flake.lock +use tools_common::{self, *}; + +struct InputRow { + name: String, + type_: String, + location: String, // "owner/repo (branch)" or URL fragment + rev: String, + age_days: i64, +} + +fn current_unix_time() -> i64 { + let out = Command::new("date").arg("+%s").output().expect("date failed"); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) +} + +/// Run jq against the lock file and parse each non-root node into an InputRow. +fn parse_lock(lock_path: &Path) -> Vec { + // Extract: name, type, owner, repo, rev, lastModified, ref/branch + let prog = concat!( + ".nodes | to_entries[] ", + "| select(.key != \"root\") ", + "| select(.value.locked != null) ", + "| [ .key,", + " (.value.locked.type // \"?\"),", + " (.value.locked.owner // \"-\"),", + " (.value.locked.repo // \"-\"),", + " (.value.locked.rev[0:8] // \"-\"),", + " ((.value.locked.lastModified // 0) | tostring),", + " (.value.original.ref // .value.original.rev // \"-\")", + "] | @tsv" + ); + + let out = Command::new("jq") + .args(["-r", prog, lock_path.to_str().unwrap()]) + .output() + .expect("jq failed β€” is it installed?"); + + if !out.status.success() { + eprintln!("❌ jq error:\n{}", String::from_utf8_lossy(&out.stderr)); + return vec![]; + } + + let now = current_unix_time(); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let c: Vec<&str> = line.splitn(7, '\t').collect(); + if c.len() < 7 { return None; } + let ts: i64 = c[5].parse().unwrap_or(0); + let age_days = if ts > 0 { (now - ts) / 86400 } else { -1 }; + let location = if c[1] == "github" { + format!("{}/{} ({})", c[2], c[3], c[6]) + } else { + c[3].to_string() + }; + Some(InputRow { + name: c[0].to_string(), + type_: c[1].to_string(), + location, + rev: c[4].to_string(), + age_days, + }) + }) + .collect() +} + +fn run_update(repo_root: &Path, input: Option<&str>) { + let mut cmd = Command::new("nix"); + cmd.current_dir(repo_root).arg("flake"); + + match input { + Some(name) => { + println!("πŸ”„ Updating input: {name}"); + cmd.args(["update", name]); + } + None => { + println!("πŸ”„ Updating all inputs…"); + cmd.arg("update"); + } + } + + let ok = cmd.status().map(|s| s.success()).unwrap_or(false); + if ok { + println!("βœ… Done. Run `nrs` to apply."); + git_sync("flake.lock", "flake"); + } else { + eprintln!("❌ Update failed."); + std::process::exit(1); + } +} + +fn main() -> io::Result<()> { + let args: Vec = env::args().collect(); + let repo_root = git_root(); + let lock_path = repo_root.join("flake.lock"); + + if !lock_path.exists() { + eprintln!("❌ flake.lock not found at {}", lock_path.display()); + std::process::exit(1); + } + + // ── --update-all ──────────────────────────────────────────────────────── + if args.iter().any(|a| a == "--update-all") { + run_update(&repo_root, None); + return Ok(()); + } + + // ── --update ───────────────────────────────────────────────────── + if let Some(pos) = args.iter().position(|a| a == "--update") { + let name = args.get(pos + 1).map(String::as_str).unwrap_or_else(|| { + eprintln!("Usage: flake-bump --update "); + std::process::exit(1); + }); + run_update(&repo_root, Some(name)); + return Ok(()); + } + + // ── staleness table ───────────────────────────────────────────────────── + let rows = parse_lock(&lock_path); + if rows.is_empty() { + eprintln!("No inputs found in flake.lock"); + return Ok(()); + } + + println!("πŸ“¦ Flake input staleness ({})\n", lock_path.display()); + + // column widths + let w_name = rows.iter().map(|r| r.name.len()).max().unwrap_or(10).max(5); + let w_loc = rows.iter().map(|r| r.location.len()).max().unwrap_or(20).min(45).max(8); + + println!("{: = vec![]; + + for r in &rows { + let age_str = if r.age_days < 0 { + " ?".to_string() + } else { + format!("{:>3}d", r.age_days) + }; + + let badge = if r.age_days < 0 || r.age_days > 90 { + stale.push(r.name.clone()); + "⚠️ " + } else if r.age_days > 30 { + "🟑" + } else { + "βœ…" + }; + + let loc = if r.location.len() > w_loc { + format!("{}…", &r.location[..w_loc.saturating_sub(1)]) + } else { + r.location.clone() + }; + + println!("{:90d): {}", stale.join(", ")); + println!(); + println!(" Bump one : flake-bump --update "); + println!(" Bump all : flake-bump --update-all"); + } + + Ok(()) +} diff --git a/tools/src/bin/gen-diff.rs b/tools/src/bin/gen-diff.rs new file mode 100644 index 0000000..00fd2a7 --- /dev/null +++ b/tools/src/bin/gen-diff.rs @@ -0,0 +1,152 @@ +/// gen-diff β€” show what changed between nix-darwin / NixOS generations. +/// +/// Usage: +/// gen-diff diff last two generations +/// gen-diff --list list all generations with dates +/// gen-diff --from N --to M diff specific generation numbers +use tools_common::{self, *}; + +const PROFILES_DIR: &str = "/nix/var/nix/profiles"; + +struct Gen { + number: u32, + path: PathBuf, +} + +fn find_generations() -> Vec { + let dir = PathBuf::from(PROFILES_DIR); + let mut gens: Vec = fs::read_dir(&dir) + .unwrap_or_else(|_| { + eprintln!("❌ Cannot read {PROFILES_DIR} β€” are you on a Nix system?"); + std::process::exit(1); + }) + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + // Match "system-N-link" + let n_str = name.strip_prefix("system-")?.strip_suffix("-link")?; + let n: u32 = n_str.parse().ok()?; + Some(Gen { number: n, path: e.path() }) + }) + .collect(); + + gens.sort_by_key(|g| g.number); + gens +} + +/// Return the mtime of a path as "YYYY-MM-DD HH:MM". +/// Uses `date -r ` which works on both macOS and Linux (GNU). +fn path_date(path: &Path) -> String { + let out = Command::new("date") + .args(["-r", path.to_str().unwrap_or(""), "+%Y-%m-%d %H:%M"]) + .output(); + match out { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), + _ => "?".to_string(), + } +} + +fn diff_gens(from: &Gen, to: &Gen) { + println!("πŸ” Generation diff\n"); + println!(" From : gen {:>3} ({})", from.number, path_date(&from.path)); + println!(" To : gen {:>3} ({})\n", to.number, path_date(&to.path)); + println!("{}", "─".repeat(60)); + + // nix store diff-closures prints a human-readable package diff + let diff = Command::new("nix") + .args([ + "store", "diff-closures", + from.path.to_str().unwrap(), + to.path.to_str().unwrap(), + ]) + .output() + .expect("nix not found"); + + let stdout = String::from_utf8_lossy(&diff.stdout); + let stderr = String::from_utf8_lossy(&diff.stderr); + + if stdout.trim().is_empty() && stderr.trim().is_empty() { + println!("\n✨ No package changes between these generations."); + } else { + if !stdout.is_empty() { print!("{stdout}"); } + if !stderr.is_empty() { eprint!("{stderr}"); } + } +} + +fn list_gens(gens: &[Gen]) { + println!("πŸ“‹ System generations\n"); + println!(" {:<6} {:<17} PATH", "GEN", "BUILT"); + println!(" {}", "─".repeat(70)); + + // Resolve current symlink to highlight it + let current = fs::read_link(PathBuf::from(PROFILES_DIR).join("system")) + .unwrap_or_default(); + + for g in gens.iter().rev() { // newest first + let date = path_date(&g.path); + let real = fs::read_link(&g.path).unwrap_or_default(); + let marker = if real == current { " ← current" } else { "" }; + println!(" {:<6} {:<17} {}{}", + g.number, date, g.path.display(), marker); + } +} + +fn main() -> io::Result<()> { + let args: Vec = env::args().collect(); + let gens = find_generations(); + + if gens.is_empty() { + eprintln!("❌ No system generations found in {PROFILES_DIR}"); + std::process::exit(1); + } + + // ── --list ─────────────────────────────────────────────────────────────── + if args.iter().any(|a| a == "--list") { + list_gens(&gens); + return Ok(()); + } + + // ── parse optional --from N --to M ──────────────────────────────────── + let parse_flag = |flag: &str| -> Option { + args.windows(2) + .find(|w| w[0] == flag) + .and_then(|w| w[1].parse().ok()) + }; + + let find_gen = |n: u32| -> Option<&Gen> { gens.iter().find(|g| g.number == n) }; + + let from = match parse_flag("--from") { + Some(n) => find_gen(n).unwrap_or_else(|| { + eprintln!("❌ Generation {n} not found"); + std::process::exit(1); + }), + None if gens.len() >= 2 => &gens[gens.len() - 2], + _ => { + eprintln!("ℹ️ Only one generation exists β€” nothing to diff."); + list_gens(&gens); + return Ok(()); + } + }; + + let to = match parse_flag("--to") { + Some(n) => find_gen(n).unwrap_or_else(|| { + eprintln!("❌ Generation {n} not found"); + std::process::exit(1); + }), + None => gens.last().unwrap(), + }; + + if from.number == to.number { + eprintln!("⚠️ --from and --to are the same generation ({})", from.number); + std::process::exit(1); + } + + diff_gens(from, to); + + println!("\nπŸ’‘ Tips"); + println!(" List all generations : gen-diff --list"); + println!(" Specific range : gen-diff --from N --to M"); + println!(" Roll back : sudo nix-env --rollback --profile {PROFILES_DIR}/system"); + + Ok(()) +} diff --git a/tools/src/bin/health-check.rs b/tools/src/bin/health-check.rs new file mode 100644 index 0000000..f81a6bf --- /dev/null +++ b/tools/src/bin/health-check.rs @@ -0,0 +1,221 @@ +/// health-check β€” pre-rebuild preflight for the nix config. +/// +/// Exits 0 if all hard checks pass (warnings are non-fatal). +/// Run before `nrs` to catch problems early. +use tools_common::{self, *}; + +// ── result types ───────────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq)] +enum Status { Pass, Warn, Fail } + +struct Check { + name: &'static str, + status: Status, + detail: String, +} + +impl Check { + fn pass(name: &'static str, detail: impl Into) -> Self { + Self { name, status: Status::Pass, detail: detail.into() } + } + fn warn(name: &'static str, detail: impl Into) -> Self { + Self { name, status: Status::Warn, detail: detail.into() } + } + fn fail(name: &'static str, detail: impl Into) -> Self { + Self { name, status: Status::Fail, detail: detail.into() } + } + fn icon(&self) -> &str { + match self.status { + Status::Pass => "βœ…", + Status::Warn => "⚠️ ", + Status::Fail => "❌", + } + } +} + +// ── individual checks ──────────────────────────────────────────────────────── + +fn check_nix_daemon() -> Check { + let ok = Command::new("nix") + .args(["store", "ping"]) + .stdout(Stdio::null()).stderr(Stdio::null()) + .status().map(|s| s.success()).unwrap_or(false); + if ok { + Check::pass("Nix daemon", "responding") + } else { + Check::fail("Nix daemon", "not responding β€” try: sudo launchctl start org.nixos.nix-daemon") + } +} + +fn check_flake_lock(repo_root: &Path) -> Check { + let lock = repo_root.join("flake.lock"); + if !lock.exists() { + return Check::fail("flake.lock", "missing β€” run: nix flake update"); + } + // Validate JSON with jq + let ok = Command::new("jq") + .args(["-e", ".nodes.root", lock.to_str().unwrap()]) + .stdout(Stdio::null()).stderr(Stdio::null()) + .status().map(|s| s.success()).unwrap_or(false); + if ok { + Check::pass("flake.lock", "present and valid JSON") + } else { + Check::fail("flake.lock", "present but invalid β€” run: nix flake update") + } +} + +fn check_flake_eval(repo_root: &Path) -> Check { + // Try darwin first, fall back to nixos + for (attr, label) in [ + (".#darwinConfigurations", "darwinConfigurations"), + (".#nixosConfigurations", "nixosConfigurations"), + ] { + let out = Command::new("nix") + .current_dir(repo_root) + .args(["eval", attr, "--apply", "builtins.attrNames", "--no-build"]) + .stdout(Stdio::piped()).stderr(Stdio::piped()) + .output(); + + match out { + Ok(o) if o.status.success() => { + let names = String::from_utf8_lossy(&o.stdout).trim().to_string(); + return Check::pass("Flake evaluates", format!("{label}: {names}")); + } + Ok(o) => { + let err = String::from_utf8_lossy(&o.stderr); + // If it simply doesn't have this attr, try the other one + if err.contains("does not provide attribute") { continue; } + let short = err.lines().next().unwrap_or("?").trim(); + return Check::fail("Flake evaluates", short.to_string()); + } + Err(e) => return Check::fail("Flake evaluates", format!("nix not found: {e}")), + } + } + Check::fail("Flake evaluates", "no darwinConfigurations or nixosConfigurations found") +} + +fn check_git_state(repo_root: &Path) -> Check { + let out = Command::new("git") + .current_dir(repo_root) + .args(["status", "--porcelain"]) + .output(); + match out { + Ok(o) if o.stdout.is_empty() => Check::pass("Git tree", "clean"), + Ok(o) => { + let n = String::from_utf8_lossy(&o.stdout).lines().count(); + Check::warn("Git tree", + format!("{n} uncommitted change(s) β€” commit after rebuilding to keep history clean")) + } + _ => Check::warn("Git tree", "could not determine status"), + } +} + +fn check_age_key() -> Check { + let home = env::var("HOME").unwrap_or_default(); + let path = PathBuf::from(&home).join(".config/age/keys.txt"); + if path.exists() { + Check::pass("Age key", path.display().to_string()) + } else { + Check::fail("Age key", + format!("not found at {} β€” run: secrets-setup", path.display())) + } +} + +fn check_ssh_keys(repo_root: &Path) -> Check { + let path = repo_root.join("modules/ssh-keys.nix"); + if !path.exists() { + return Check::warn("SSH keys", "modules/ssh-keys.nix not found"); + } + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => return Check::fail("SSH keys", format!("read error: {e}")), + }; + let count = content.lines().filter(|l| l.contains("ssh-ed25519 AAAA")).count(); + if count > 0 { + Check::pass("SSH keys", format!("{count} valid ed25519 key(s) in ssh-keys.nix")) + } else { + Check::warn("SSH keys", "no valid ed25519 keys found in modules/ssh-keys.nix") + } +} + +fn check_homebrew() -> Check { + let out = Command::new("which").arg("brew").output(); + match out { + Ok(o) if o.status.success() => { + Check::pass("Homebrew", String::from_utf8_lossy(&o.stdout).trim().to_string()) + } + _ => Check::fail("Homebrew", "not installed β€” required by modules/darwin/homebrew.nix"), + } +} + +fn check_disk_space() -> Check { + // Warn if /nix/store partition has < 5 GB free + let out = Command::new("df") + .args(["-k", "/nix/store"]) + .output(); + if let Ok(o) = out { + let text = String::from_utf8_lossy(&o.stdout); + if let Some(line) = text.lines().nth(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + // df -k: 1=blocks, 2=used, 3=available + if let Some(avail_kb) = cols.get(3).and_then(|s| s.parse::().ok()) { + let avail_gb = avail_kb / 1_048_576; + return if avail_gb >= 5 { + Check::pass("Disk space", format!("{avail_gb} GB free on /nix/store")) + } else { + Check::warn("Disk space", + format!("only {avail_gb} GB free β€” run: sudo nix-collect-garbage -d")) + }; + } + } + } + Check::warn("Disk space", "could not determine free space") +} + +// ── main ───────────────────────────────────────────────────────────────────── + +fn main() -> io::Result<()> { + let repo_root = git_root(); + let is_darwin = cfg!(target_os = "macos"); + + println!("πŸ₯ Nix config health check\n"); + println!(" Repo : {}", repo_root.display()); + println!(" Host : {}\n", get_hostname()); + + let mut checks = vec![ + check_nix_daemon(), + check_flake_lock(&repo_root), + check_flake_eval(&repo_root), + check_git_state(&repo_root), + check_age_key(), + check_ssh_keys(&repo_root), + check_disk_space(), + ]; + + if is_darwin { + checks.push(check_homebrew()); + } + + // Print results + let name_w = checks.iter().map(|c| c.name.len()).max().unwrap_or(10); + for c in &checks { + println!(" {} {: