Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ static PASSWORD_CACHE: OnceLock<Mutex<HashMap<String, SecretString>>> =

fn get_cached_password(host: &str) -> Option<SecretString> {
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);
}

Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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}"));
}
},
_ => {},
Expand Down
27 changes: 25 additions & 2 deletions src/darwin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::path::PathBuf>,
) -> &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<std::path::PathBuf>,
) -> 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.
///
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
32 changes: 23 additions & 9 deletions src/home.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -99,17 +102,28 @@ impl HomeRebuildArgs {
.run()
.wrap_err("Failed to build Home-Manager configuration")?;

let prev_generation: Option<PathBuf> = [
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<PathBuf> = if profile_path.exists() {
Some(profile_path.clone())
} else {
None
};

debug!("Previous generation: {prev_generation:?}");

Expand Down Expand Up @@ -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 => {
Expand Down
62 changes: 61 additions & 1 deletion src/interface.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf, String> {
let path = Path::new(s);

// `bail!` is for early returns in functions that return `Result<T, E>`, 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,
Expand Down Expand Up @@ -228,6 +272,10 @@ pub struct OsRebuildArgs {
/// Build the configuration to a different host over ssh
#[arg(long)]
pub build_host: Option<String>,

/// Path to Nix' system profile
#[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)]
pub profile: Option<std::path::PathBuf>,
}

impl OsRebuildArgs {
Expand Down Expand Up @@ -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<std::path::PathBuf>,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -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<String>,

/// Path to Home-Manager profile
#[arg(long, short = 'P', value_hint = clap::ValueHint::FilePath, value_parser = symlink_path_validator)]
pub profile: Option<std::path::PathBuf>,
}

impl HomeRebuildArgs {
Expand Down Expand Up @@ -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<std::path::PathBuf>,
}

impl DarwinRebuildArgs {
Expand Down
56 changes: 47 additions & 9 deletions src/nixos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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 \
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(&current_gen_link)
.arg(SYSTEM_PROFILE)
.arg(system_profile_path)
.elevate(elevate.then_some(elevation))
.message("Rolling back system profile")
.with_required_env()
Expand Down