diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a00a0f..a443c276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ be put in the "Changed" section or, if it's just to remove code or functionality, under the "Removed" section. --> +## Unreleased + +### Added + +- `os`, `home` and `darwin` subcommands now accept a `--profile` flag. + - For `nh os` subcommands, `--profile` allows you to override the default system profile path for all system operations, including builds, rollbacks, and generation queries. If the flag is not set, the default system profile is used. If the path does not exist, nh will error out. + - For `nh home` and `nh darwin`, `--profile` similarly overrides the default profile path for home-manager and nix-darwin operations, with the same fallback and error behavior. + ## 4.2.0 ### Changed diff --git a/src/commands.rs b/src/commands.rs index 644b6ae3..af6d7df8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -22,13 +22,17 @@ static PASSWORD_CACHE: OnceLock>> = fn get_cached_password(host: &str) -> Option { let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let guard = cache.lock().unwrap_or_else(|e| e.into_inner()); + let guard = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); guard.get(host).cloned() } fn cache_password(host: &str, password: SecretString) { let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); guard.insert(host.to_string(), password); } @@ -449,7 +453,7 @@ impl Command { Some(cached_password) } else { let password = - inquire::Password::new(&format!("[sudo] password for {}:", host)) + inquire::Password::new(&format!("[sudo] password for {host}:")) .without_confirmation() .prompt() .context("Failed to read sudo password")?; @@ -492,11 +496,11 @@ impl Command { for (key, action) in &self.env_vars { match action { EnvAction::Set(value) => { - elev_cmd = elev_cmd.arg(format!("{}={}", key, value)); + elev_cmd = elev_cmd.arg(format!("{key}={value}")); }, EnvAction::Preserve => { if let Ok(value) = std::env::var(key) { - elev_cmd = elev_cmd.arg(format!("{}={}", key, value)); + elev_cmd = elev_cmd.arg(format!("{key}={value}")); } }, _ => {}, diff --git a/src/darwin.rs b/src/darwin.rs index dbf00352..ac887b55 100644 --- a/src/darwin.rs +++ b/src/darwin.rs @@ -23,6 +23,24 @@ use crate::{ const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; const CURRENT_PROFILE: &str = "/run/current-system"; +fn get_system_profile( + profile: &Option, +) -> &std::ffi::OsStr { + profile + .as_ref() + .map_or_else(|| std::ffi::OsStr::new(SYSTEM_PROFILE), |p| p.as_os_str()) +} + +fn get_current_profile_pathbuf( + profile: &Option, +) -> PathBuf { + // XXX: For Darwin, `CURRENT_PROFILE` is only used for diffing, so fallback to + // default if not set + profile + .clone() + .unwrap_or_else(|| PathBuf::from(CURRENT_PROFILE)) +} + impl DarwinArgs { /// Run the `darwin` subcommand. /// @@ -141,7 +159,10 @@ impl DarwinRebuildArgs { "Comparing with target profile: {}", target_profile.display() ); - let _ = print_dix_diff(&PathBuf::from(CURRENT_PROFILE), &target_profile); + let _ = print_dix_diff( + &get_current_profile_pathbuf(&self.profile), + &target_profile, + ); } if self.common.ask && !self.common.dry && !matches!(variant, Build) { @@ -155,8 +176,10 @@ impl DarwinRebuildArgs { } if matches!(variant, Switch) { + let profile_path = get_system_profile(&self.profile); Command::new("nix") - .args(["build", "--no-link", "--profile", SYSTEM_PROFILE]) + .args(["build", "--no-link", "--profile"]) + .arg(profile_path) .arg(&out_path) .elevate(Some(elevation.clone())) .dry(self.common.dry) diff --git a/src/home.rs b/src/home.rs index ffcc6955..3ea74577 100644 --- a/src/home.rs +++ b/src/home.rs @@ -1,5 +1,8 @@ use std::{env, ffi::OsString, path::PathBuf}; +const USER_PROFILE_PATH: &str = "/nix/var/nix/profiles/per-user"; +const HOME_PROFILE_PATH: &str = ".local/state/nix/profiles/home-manager"; + use color_eyre::{ Result, eyre::{Context, bail, eyre}, @@ -99,17 +102,28 @@ impl HomeRebuildArgs { .run() .wrap_err("Failed to build Home-Manager configuration")?; - let prev_generation: Option = [ - PathBuf::from("/nix/var/nix/profiles/per-user") + let profile_path = if let Some(ref profile) = self.profile { + profile.clone() + } else { + let user_profile = PathBuf::from(USER_PROFILE_PATH) .join(env::var("USER").map_err(|_| eyre!("Couldn't get username"))?) - .join("home-manager"), - PathBuf::from( + .join("home-manager"); + let home_profile = PathBuf::from( env::var("HOME").map_err(|_| eyre!("Couldn't get home directory"))?, ) - .join(".local/state/nix/profiles/home-manager"), - ] - .into_iter() - .find(|next| next.exists()); + .join(HOME_PROFILE_PATH); + if user_profile.exists() { + user_profile + } else { + home_profile + } + }; + + let prev_generation: Option = if profile_path.exists() { + Some(profile_path.clone()) + } else { + None + }; debug!("Previous generation: {prev_generation:?}"); @@ -137,7 +151,7 @@ impl HomeRebuildArgs { out_path.clone() }; - // just do nothing for None case (fresh installs) + // Just do nothing for None case (fresh installs) if let Some(generation) = prev_generation { match self.common.diff { DiffType::Never => { diff --git a/src/interface.rs b/src/interface.rs index 948693be..66cef616 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -1,8 +1,52 @@ -use std::{env, path::PathBuf}; +use std::{ + env, + path::{Path, PathBuf}, +}; use anstyle::Style; use clap::{Args, Parser, Subcommand, ValueEnum, builder::Styles}; use clap_verbosity_flag::InfoLevel; +use color_eyre::eyre::eyre; + +/// Validates that the provided path exists and is a symbolic link. This is done +/// in order to handle the error consistently with the rest of our crate instead +/// of letting Nix perform the validation and throw its own error kind. +/// +/// # Parameters +/// +/// - `s`: The string representation of the path to validate. +/// +/// # Returns +/// +/// - `Ok(PathBuf)`: If the path exists and is a symlink, returns the +/// canonicalized `PathBuf`. +/// - `Err(String)`: If the path does not exist or is not a symlink, returns a +/// descriptive error message suitable for display to the user. +/// +/// # Errors +/// +/// Returns an error if the path does not exist or is not a symbolic link. +fn symlink_path_validator(s: &str) -> Result { + let path = Path::new(s); + + // `bail!` is for early returns in functions that return `Result`, i.e., + // it immediately returns from the function with an error. Since this is a + // value parser and we need to return `Err(String)` `eyre!` is more + // appropriate. + if !path.exists() { + return Err( + eyre!("--profile path provided but does not exist: {}", s).to_string(), + ); + } + + if !path.is_symlink() { + return Err( + eyre!("--profile path exists but is not a symlink: {}", s).to_string(), + ); + } + + Ok(path.to_path_buf()) +} use crate::{ Result, @@ -228,6 +272,10 @@ pub struct OsRebuildArgs { /// Build the configuration to a different host over ssh #[arg(long)] pub build_host: Option, + + /// Path to Nix' system profile + #[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)] + pub profile: Option, } impl OsRebuildArgs { @@ -285,6 +333,10 @@ pub struct OsRollbackArgs { /// Whether to display a package diff #[arg(long, short, value_enum, default_value_t = DiffType::Auto)] pub diff: DiffType, + + /// Path to Nix' system profile for rollback + #[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)] + pub profile: Option, } #[derive(Debug, Args)] @@ -514,6 +566,10 @@ pub struct HomeRebuildArgs { /// Move existing files by backing up with this file extension #[arg(long, short = 'b')] pub backup_extension: Option, + + /// Path to Home-Manager profile + #[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)] + pub profile: Option, } impl HomeRebuildArgs { @@ -623,6 +679,10 @@ pub struct DarwinRebuildArgs { /// Don't panic if calling nh as root #[arg(short = 'R', long, env = "NH_BYPASS_ROOT_CHECK")] pub bypass_root_check: bool, + + /// Path to Darwin system profile + #[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)] + pub profile: Option, } impl DarwinRebuildArgs { diff --git a/src/nixos.rs b/src/nixos.rs index 86030eb7..d2b179f6 100644 --- a/src/nixos.rs +++ b/src/nixos.rs @@ -229,8 +229,13 @@ impl OsRebuildArgs { match self.common.diff { DiffType::Always => { - let _ = - print_dix_diff(&PathBuf::from(CURRENT_PROFILE), &target_profile); + let _ = print_dix_diff( + &self + .profile + .as_ref() + .map_or_else(|| PathBuf::from(CURRENT_PROFILE), PathBuf::from), + &target_profile, + ); }, DiffType::Never => { debug!("Not running dix as the --diff flag is set to never."); @@ -244,8 +249,13 @@ impl OsRebuildArgs { "Comparing with target profile: {}", target_profile.display() ); - let _ = - print_dix_diff(&PathBuf::from(CURRENT_PROFILE), &target_profile); + let _ = print_dix_diff( + &self + .profile + .as_ref() + .map_or_else(|| PathBuf::from(CURRENT_PROFILE), PathBuf::from), + &target_profile, + ); } else { debug!( "Not running dix as the target hostname is different from the \ @@ -330,9 +340,15 @@ impl OsRebuildArgs { .canonicalize() .context("Failed to resolve output path")?; + let system_profile_path = if let Some(profile) = self.profile.as_ref() { + profile.as_os_str() + } else { + std::ffi::OsStr::new(SYSTEM_PROFILE) + }; Command::new("nix") .elevate(elevate.then_some(elevation.clone())) - .args(["build", "--no-link", "--profile", SYSTEM_PROFILE]) + .args(["build", "--no-link", "--profile"]) + .arg(system_profile_path) .arg(&canonical_out_path) .ssh(self.target_host.clone()) .with_required_env() @@ -401,7 +417,12 @@ impl OsRollbackArgs { info!("Rolling back to generation {}", target_generation.number); // Construct path to the generation - let profile_dir = Path::new(SYSTEM_PROFILE).parent().unwrap_or_else(|| { + let system_profile_path = if let Some(profile) = self.profile.as_ref() { + profile.as_path() + } else { + Path::new(SYSTEM_PROFILE) + }; + let profile_dir = system_profile_path.parent().unwrap_or_else(|| { tracing::warn!( "SYSTEM_PROFILE has no parent, defaulting to /nix/var/nix/profiles" ); @@ -432,7 +453,13 @@ impl OsRollbackArgs { "Comparing with target profile: {}", generation_link.display() ); - let _ = print_dix_diff(&PathBuf::from(CURRENT_PROFILE), &generation_link); + let _ = print_dix_diff( + &self + .profile + .as_ref() + .map_or_else(|| PathBuf::from(CURRENT_PROFILE), PathBuf::from), + &generation_link, + ); } if self.dry { @@ -469,10 +496,15 @@ impl OsRollbackArgs { info!("Setting system profile..."); // Instead of direct symlink operations, use a command with proper elevation + let system_profile_path = if let Some(profile) = self.profile.as_ref() { + profile.as_path() + } else { + Path::new(SYSTEM_PROFILE) + }; Command::new("ln") .arg("-sfn") // force, symbolic link .arg(&generation_link) - .arg(SYSTEM_PROFILE) + .arg(system_profile_path) .elevate(elevate.then_some(elevation.clone())) .message("Setting system profile") .with_required_env() @@ -534,10 +566,16 @@ impl OsRollbackArgs { let current_gen_link = profile_dir.join(format!("system-{current_gen_number}-link")); + let system_profile_path = if let Some(profile) = self.profile.as_ref() + { + profile.as_path() + } else { + Path::new(SYSTEM_PROFILE) + }; Command::new("ln") .arg("-sfn") // Force, symbolic link .arg(¤t_gen_link) - .arg(SYSTEM_PROFILE) + .arg(system_profile_path) .elevate(elevate.then_some(elevation)) .message("Rolling back system profile") .with_required_env()