diff --git a/.gitignore b/.gitignore
index 516e5686..0823ee01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
# Rust
target
-gen*
-!generations.rs
+/comp
+/man
# Nix
.direnv
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db69a48d..b2434bf7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
# NH Changelog
+
@@ -31,26 +31,30 @@
## What Does it Do?
NH is a modern helper utility that aims to consolidate and reimplement some of
-the commands from various tools within the NixOS ecosystem. Our goal is to
-provide a cohesive, easily-understandable interface with more features, better
-ergonomics and at many times better _speed_. In addition to bringing together
-relevant 3rd party projects, NH also acts a super-convenient all-in-one utility
-that reimplements well known Nix commands.
+the commands and interfaces from various tools within the Nix/NixOS ecosystem.
+Our goal is to provide a **cohesive**, **easily-understandable** interface with
+more features, better ergonomics and at many times better _speed_. In addition
+to acting as a super-convenient, all-in-one utility that reimplements well-known
+and commonly used Nix commands, NH is a _pretty_ tool that brings together
+relevant 3rd party projects that you might be familiar with.
+
+To get started with NH, skip to the [Usage] section.
## Features
-- **Unified CLI**: Consistent, intuitive interface for NixOS, Home Manager, and
- Darwin workflows.
-- **Rich Interface**: Each major function (`os`, `home`, `darwin`, `search`,
- `clean`) exposes granular subcommands and flags for fine-tuned control.
+- **Unified CLI**: Consistent, intuitive interface for many **Nix**, **NixOS**,
+ **Home Manager**, and **Darwin** workflows.
+ - **Rich Interface**: Each major function (`os`, `home`, `darwin`, `search`,
+ `clean`) exposes granular subcommands and flags for fine-tuned control.
+ - **Enhanced Garbage Collection**: `nh clean` extends `nix-collect-garbage`
+ with gcroot cleanup, profile targeting, and time-based retention.
+ - **Faster Nix Search**: search Nixpkgs via Elasticsearch for faster results
- **Eye Candy**: It looks great, without any compromise. I mean who does not
love some cool looking UIs?
-- **Enhanced Garbage Collection**: `nh clean` extends `nix-collect-garbage` with
- gcroot cleanup, profile targeting, and time-based retention.
-- **Build-tree Visualization**: `nh os` and similar commands display build trees
- for clear dependency tracking.
-- **Diff & Change Review**: Integrated, super-fast diffing of derivation changes
- before activation or switch.
+ - **Build-tree Visualization**: `nh os` and similar commands display build
+ trees for clear dependency tracking.
+ - **Diff & Change Review**: Integrated, super-fast diffing of derivation
+ changes before activation or switch.
- **Specialisation Support**: Easily select or ignore NixOS & Home-Manager
specialisations via flags.
- **Generation Management**: Inspect, rollback, and manage system generations
@@ -58,6 +62,32 @@ that reimplements well known Nix commands.
- **Extensible & Futureproof**: Designed for seamless, rapid addition of new
subcommands and flags.
+### Design
+
+[Discussions]: https://github.com/nix-community/nh/discussions
+[Issues]: https://github.com/nix-community/nh/issues
+
+NH is an _unified_ CLI, meaning it aims to bring together core platform support
+into a single, convenient utility. For the time being, this appears in NH's
+interface as support for **NixOS** (first-class), **Home Manager** and
+**Nix-Darwin**. We hope to provide a familiar, convenient and _good looking_
+interface for users of any and all of those projects.
+
+The familiar interface of NH should not be seen as a weakness, however, as NH is
+NOT a nixos-rebuild wrapper, and is not constrained by the limits of such tools.
+Simply put, our goal is to implement _not only_ what is available but instead go
+above and beyond to implement additional commands and flags to **improve the
+experience provided by the default tools**. Which is to say _plumbing_ and _sane
+defaults_ matter. You get to enjoy the very tools that you are accustomed to,
+with the benefits of a faster and safer language.
+
+> [!INFO]
+> Future goals of NH include providing support for lower-levels tools, such as
+> `nixos-install`, and `nixos-generate-config` to become a drop-in replacement
+> for most critical tools with the benefits of Rust. If you would like to
+> provide feedback and create feature requests, those are welcome over at the
+> [Discussions] and [Issues] tabs respectively.
+
## Status
[update request]: https://github.com/NixOS/nixpkgs/issues
@@ -77,61 +107,7 @@ backported to the stable branch. Refer to the [installation](#installation)
section for more details. Make sure you submit an [update request] in Nixpkgs if
the package is outdated.
-## Usage
-
-One of the features and the core principles of NH is to provide a clean, uniform
-and intuitive CLI for its users. The `nh` command offers several subcommands,
-all with their extensive CLI flags for extensive configuration.
-
-### Global Subcommands
-
-- `nh search` - a super-fast package searching tool (powered by an Elasticsearch
- client) for Nix packages in supported Nixpkgs branches.
-
-
-
-
-- `nh clean` - a re-implementation of `nix-collect-garbage` that also collects
- gcroots.
-
-
-
-
-### Platform Specific Subcommands
-
-- `nh os` - reimplements `nixos-rebuild`[^1] with the addition of
- - build-tree displays.
- - diff of changes.
- - confirmation.
-
-
-
-
-- `nh home` - reimplements `home-manager`.
-- `nh darwin` - reimplements `darwin-rebuild`.
-
-[^1]: `nh os` does not yet provide full feature parity with `nixos-rebuild`.
- While a large collection of subcommands have been implemented, you might be
- missing some features. Please visit
- [#358](https://github.com/nix-community/nh/issues/358) for a roadmap.
-
-> [!TIP]
-> See the help page for individual subcommands, or `man 1 nh` for more
-> information on each subcommand.
-
-## Installation
+### Installation
The latest, tagged version is available in Nixpkgs as **NH stable**. This is
recommended for most users, as tagged releases will usually undergo more
@@ -217,6 +193,72 @@ The config would look like this:
}
```
+## Usage
+
+One of the features and the core principles of NH is to provide a clean, uniform
+and intuitive CLI for its users. The `nh` command offers several subcommands,
+all with their extensive CLI flags for extensive configuration.
+
+Under the `nh` command, there are two types of commands that you'll be
+interested in:
+
+### Global Subcommands
+
+Global subcommands implement functionality around core Nix commands. As it
+stands, we provide a **better search** and **better garbage collection**.
+
+- `nh search` - a super-fast package searching tool (powered by an Elasticsearch
+ client) for Nix packages in supported Nixpkgs branches.
+
+
+
+
+
+- `nh clean` - a re-implementation of `nix-collect-garbage` that also collects
+ gcroots.
+
+
+
+
+
+### Platform Specific Subcommands
+
+Platform specific subcommands are those that implement CLI utilities for
+**NixOS**, **Home Manager** and **Nix-Darwin**.
+
+- `nh os` - reimplements `nixos-rebuild`[^1] with the addition of
+ - build-tree displays.
+ - diff of changes.
+ - confirmation.
+
+
+
+
+
+- `nh home` - reimplements `home-manager`.
+- `nh darwin` - reimplements `darwin-rebuild`.
+
+[^1]: `nh os` does not yet provide full feature parity with `nixos-rebuild`.
+ While a large collection of subcommands have been implemented, you might be
+ missing some features. Please visit
+ [#358](https://github.com/nix-community/nh/issues/358) for a roadmap.
+
+> [!TIP]
+> See the help page for individual subcommands, or `man 1 nh` for more
+> information on each subcommand.
+
## Environment variables
NH supports several environment variables to control command behaviour. Some of
@@ -224,11 +266,14 @@ the common variables that you may encounter or choose to employ are as follows:
### Global
+- `NIX_SSHOPTS`, `NIX_CONFIG`, `NIX_REMOTE`, `NIX_SSL_CERT_FILE` and
+ `NIX_USER_CONF_FILES` are forwarded in all Nix commands with environment
+ isolation.
- `NIXOS_INSTALL_BOOTLOADER`
- This is a variable accepted by `switch-to-configuration`, which handles the
- system switching behind the scenes. If `true`, `swith-to-configuration` will
- call the necessary script to force and installation of your bootloader. This
- behaviour can also be replicated by passing `--install-bootloader` to
+ system switching behind the scenes. If `true`, `switch-to-configuration`
+ will call the necessary script to force and installation of your bootloader.
+ This behaviour can also be replicated by passing `--install-bootloader` to
`nh os switch` and `nh os boot` commands.
### NH Specific
@@ -259,6 +304,12 @@ the common variables that you may encounter or choose to employ are as follows:
elevated commands. Set to `"0"` to disable preservation, `"1"` to force
preservation. If unset, preservation defaults to enabled.
+- `NH_SHOW_SYSTEMCTL_HINTS`
+ - Whether to parse activation logs and isolate specific unit failures to
+ display them at the end of activation. Setting this to `"1"` allows
+ replicating the default behaviour from `nixos-rebuild` where failing units
+ are displayed at the end.
+
- `NH_LOG`
- Sets the tracing/log filter for NH. This uses the same format as
`tracing_subscriber` env filters (for example: `nh=trace`).
@@ -270,7 +321,7 @@ the common variables that you may encounter or choose to employ are as follows:
### Notes
- Any environment variables prefixed with `NH_` are explicitly propagated by NH
- to commands when appropriate.
+ to commands when appropriate, i.e., in environment isolation.
- For backwards compatibility, if `FLAKE` is present and none of the
command-specific `NH_*_FLAKE` variables exist, NH will set `NH_FLAKE` from
`FLAKE` and emit a warning recommending migration to `NH_FLAKE`. `FLAKE` will
@@ -284,33 +335,65 @@ run `nix develop`. We also provide a `.envrc` for Direnv users, who may use
### Structure
-NH consists of two modules. The core of NH is found in the `src` directory, and
-is separated into different modules. Some of the critical modules that you may
-want to be aware of are `nh::commands` for command interfaces, `nh::checks` for
-pre-startup checks and `nh::util` to store shared logic. Platform-specific logic
-is placed in the appropriate platform module, such as `nh::nixos` or
-`nh::darwin` with generic helpers placed in `nh::util`.
+[cargo-xtask]: https://github.com/matklad/cargo-xtask
+
+NH is written in the Rust programming language, and consists of two modules. The
+core of NH is found in the `src` directory, and is separated into different
+modules. Some of the critical modules that you may want to be aware of are
+`nh::commands` for command interfaces, `nh::checks` for pre-startup checks and
+`nh::util` to store shared logic. Platform-specific logic is placed in the
+appropriate platform module, such as `nh::nixos` or `nh::darwin` with generic
+helpers placed in `nh::util`.
-The `xtask` directory contains the cargo-xtask tasks used by NH, used to
-generate manpages and possibly more in the future. Some of the
+The `xtask` directory contains the [cargo-xtask] tasks used by NH, used to
+generate manpages and possibly more in the future.
### Submitting Changes
-Once your changes are complete, remember to run [fix.sh](./fix.sh) to apply
-general formatter and linter rules that will be expected by the CI.
+Once your changes are complete, please remember to run the fixup script in
+[fix.sh](./fix.sh) to apply general formatter and linter rules that will be
+expected by the CI. This is optional, but some CI steps (such as formatting) is
+required for a merge.
-Lastly, update the [changelog](/CHANGELOG.md) and open your pull request.
+You will also want to update the [changelog](/CHANGELOG.md) with sufficient
+amount of information to detail the new behaviour before you create your
+changes.
+
+This might seem daunting, but it isn't. Even if you _don't_ meet those
+requirements, you'll be gently nudged to make your changes. Friendly
+contributions are always welcome.
## Attributions
-[nix-output-monitor]: https://github.com/maralorn/nix-output-monitor
+[faukah]: https://github.com/faukah
+[ViperML]: https://github.com/viperML
+
+NH has had a long history, and it has grown a lot over the years. I, NotAShelf,
+would first like to extend my thanks to [ViperML] for his immense efforts as the
+creator and the first maintainer of NH. I have recently taken over NH, but none
+of this would be possible without him.
+
+[nvd]: https://sr.ht/~khumba/nvd/
[dix]: https://github.com/bloxx12/dix
-NH would not be possible without all the tools we run under the hood
+Next, I would like to thank the tools we run under the hood. The visualization
+of upgrade diffs are provided by the [dix] crate, which is created by my good
+friend [faukah]. Compared to the previous diffing utility, [nvd], dix is more
+than twice as fast and has been a blessing to NH's diffing experience. Thank
+you!
+
+[nix-output-monitor]: https://github.com/maralorn/nix-output-monitor
+
+Another utility worth a mention is [nix-output-monitor], which NH under the hood
+for the pretty tree of builds. A big shoutout to nix-output-monitor for
+providing many NH users such as myself with pretty build visuals.
+
+[crates]: ./Cargo.toml
-- Tree of builds with [nix-output-monitor].
-- Visualization of the upgrade diff with [dix].
-- And of course, all the [crates](./Cargo.toml) we depend on.
+I also would like to extend my thanks to the many Rust [crates] that power NH
+under the hood and give it its signature UX. Without the beautiful Rust
+ecosystem, NH could not be where it is.
-Last but not least, thank you to those who contributed to NH or simply talked
-about it on various channels. NH would not be where it is without you.
+Last but not least I would like to thank... YOU! Thank you to everyone who has
+contributed to NH, talked about NH or criticized NH to allow further
+improvement. NH would not be where it is without you.
diff --git a/package.nix b/package.nix
index 7a5f8a29..e84e645c 100644
--- a/package.nix
+++ b/package.nix
@@ -1,5 +1,6 @@
{
lib,
+ stdenv,
rustPlatform,
installShellFiles,
makeBinaryWrapper,
@@ -10,7 +11,7 @@
assert use-nom -> nix-output-monitor != null;
let
runtimeDeps = lib.optionals use-nom [ nix-output-monitor ];
- cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
+ cargoToml = lib.importTOML ./Cargo.toml;
in
rustPlatform.buildRustPackage {
pname = "nh";
@@ -30,23 +31,25 @@ rustPlatform.buildRustPackage {
};
strictDeps = true;
+ nativeBuildInputs = [ makeBinaryWrapper ];
- nativeBuildInputs = [
- installShellFiles
- makeBinaryWrapper
- ];
+ cargoLock.lockFile = ./Cargo.lock;
- postInstall = ''
- mkdir completions man
+ postInstall = lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) ''
+ # Run both shell completion and manpage generation tasks. Unlike the
+ # fine-grained variants, the 'dist' command doesn't allow specifying the
+ # path but that's fine, because we can simply install them from the implicit
+ # output directories.
+ cargo xtask dist
- for shell in bash zsh fish nu; do
- NH_NO_CHECKS=1 $out/bin/nh completions $shell > completions/nh.$shell
+ # The dist task above should've created
+ # 1. Shell completions in comp/
+ # 2. The NH manpage (nh.1) in man/
+ # Let's install those.
+ for dir in comp man; do
+ mkdir -p "$out/share/$dir"
+ cp -rf "$dir" "$out/share/"
done
-
- installShellCompletion completions/*
-
- cargo xtask man --out-dir gen
- installManPage gen/nh.1
'';
postFixup = ''
@@ -54,8 +57,6 @@ rustPlatform.buildRustPackage {
--prefix PATH : ${lib.makeBinPath runtimeDeps}
'';
- cargoLock.lockFile = ./Cargo.lock;
-
env.NH_REV = rev;
meta = {
diff --git a/src/completion.rs b/src/completion.rs
deleted file mode 100644
index 29a44686..00000000
--- a/src/completion.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-use clap_complete::generate;
-use color_eyre::Result;
-use tracing::instrument;
-
-use crate::{interface, interface::Main};
-
-impl interface::CompletionArgs {
- #[instrument(ret, level = "trace")]
- /// Run the completion subcommand.
- ///
- /// # Errors
- ///
- /// Returns an error if completion script generation or output fails.
- #[cfg_attr(feature = "hotpath", hotpath::measure)]
- pub fn run(&self) -> Result<()> {
- let mut cmd = ::command();
- match self.shell {
- interface::Shell::Bash => {
- generate(
- clap_complete::Shell::Bash,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- interface::Shell::Elvish => {
- generate(
- clap_complete::Shell::Elvish,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- interface::Shell::Fish => {
- generate(
- clap_complete::Shell::Fish,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- interface::Shell::PowerShell => {
- generate(
- clap_complete::Shell::PowerShell,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- interface::Shell::Zsh => {
- generate(
- clap_complete::Shell::Zsh,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- interface::Shell::Nushell => {
- generate(
- clap_complete_nushell::Nushell,
- &mut cmd,
- "nh",
- &mut std::io::stdout(),
- )
- },
- }
- Ok(())
- }
-}
diff --git a/src/darwin.rs b/src/darwin.rs
index 98fc4bbf..4ace4c51 100644
--- a/src/darwin.rs
+++ b/src/darwin.rs
@@ -59,7 +59,9 @@ impl DarwinRebuildArgs {
use DarwinRebuildVariant::{Build, Switch};
if nix::unistd::Uid::effective().is_root() && !self.bypass_root_check {
- bail!("Don't run nh os as root. I will call sudo internally as needed");
+ bail!(
+ "Don't run nh darwin as root. I will call sudo internally as needed"
+ );
}
if self.update_args.update_all || self.update_args.update_input.is_some() {
@@ -72,7 +74,7 @@ impl DarwinRebuildArgs {
if let Some(ref p) = self.common.out_link {
(p.clone(), None)
} else {
- let dir = tempfile::Builder::new().prefix("nh-os").tempdir()?;
+ let dir = tempfile::Builder::new().prefix("nh-darwin").tempdir()?;
(dir.as_ref().join("result"), Some(dir))
};
diff --git a/src/interface.rs b/src/interface.rs
index 1a7bd410..35ead7ba 100644
--- a/src/interface.rs
+++ b/src/interface.rs
@@ -66,8 +66,6 @@ pub enum NHCommand {
Darwin(DarwinArgs),
Search(SearchArgs),
Clean(CleanProxy),
- #[command(hide = true)]
- Completions(CompletionArgs),
}
impl NHCommand {
@@ -79,7 +77,6 @@ impl NHCommand {
Self::Darwin(args) => args.get_feature_requirements(),
Self::Search(_) => Box::new(NoFeatures),
Self::Clean(_) => Box::new(NoFeatures),
- Self::Completions(_) => Box::new(NoFeatures),
}
}
@@ -97,7 +94,6 @@ impl NHCommand {
},
Self::Search(args) => args.run(),
Self::Clean(proxy) => proxy.command.run(elevation),
- Self::Completions(args) => args.run(),
Self::Home(args) => {
unsafe {
std::env::set_var("NH_CURRENT_COMMAND", "home");
@@ -134,8 +130,14 @@ impl OsArgs {
},
OsSubcommand::Switch(args)
| OsSubcommand::Boot(args)
- | OsSubcommand::Test(args)
- | OsSubcommand::Build(args) => {
+ | OsSubcommand::Test(args) => {
+ if args.rebuild.uses_flakes() {
+ Box::new(FlakeFeatures)
+ } else {
+ Box::new(LegacyFeatures)
+ }
+ },
+ OsSubcommand::Build(args) => {
if args.uses_flakes() {
Box::new(FlakeFeatures)
} else {
@@ -159,13 +161,13 @@ impl OsArgs {
#[derive(Debug, Subcommand)]
pub enum OsSubcommand {
/// Build and activate the new configuration, and make it the boot default
- Switch(OsRebuildArgs),
+ Switch(OsRebuildActivateArgs),
/// Build the new configuration and make it the boot default
- Boot(OsRebuildArgs),
+ Boot(OsRebuildActivateArgs),
/// Build and activate the new configuration
- Test(OsRebuildArgs),
+ Test(OsRebuildActivateArgs),
/// Build the new configuration
Build(OsRebuildArgs),
@@ -239,6 +241,16 @@ pub struct OsRebuildArgs {
pub build_host: Option,
}
+#[derive(Debug, Args)]
+pub struct OsRebuildActivateArgs {
+ #[command(flatten)]
+ pub rebuild: OsRebuildArgs,
+
+ /// Show systemctl debugging hints when systemd services fail
+ #[arg(long, env = "NH_SHOW_SYSTEMCTL_HINTS")]
+ pub show_systemctl_hints: bool,
+}
+
impl OsRebuildArgs {
#[must_use]
pub fn uses_flakes(&self) -> bool {
@@ -571,30 +583,6 @@ impl HomeReplArgs {
}
}
-#[derive(Debug, Parser)]
-/// Generate shell completion files into stdout
-pub struct CompletionArgs {
- /// Name of the shell
- pub shell: Shell,
-}
-
-#[derive(Debug, Clone, ValueEnum)]
-#[non_exhaustive]
-pub enum Shell {
- #[value(name = "bash")]
- Bash,
- #[value(name = "elvish")]
- Elvish,
- #[value(name = "fish")]
- Fish,
- #[value(alias = "powershell_ise", name = "powershell")]
- PowerShell,
- #[value(name = "zsh")]
- Zsh,
- #[value(alias = "nu", name = "nushell")]
- Nushell,
-}
-
/// Nix-darwin functionality
///
/// Implements functionality mostly around but not exclusive to darwin-rebuild
diff --git a/src/lib.rs b/src/lib.rs
index b6b63626..8d2ad92e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,7 +2,6 @@
pub mod checks;
pub mod clean;
pub mod commands;
-pub mod completion;
pub mod darwin;
pub mod generations;
pub mod home;
diff --git a/src/main.rs b/src/main.rs
index 0b98c8c2..5e442d09 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,6 @@
mod checks;
mod clean;
mod commands;
-mod completion;
mod darwin;
mod generations;
mod home;
diff --git a/src/nixos.rs b/src/nixos.rs
index 02bf7b83..6edcd300 100644
--- a/src/nixos.rs
+++ b/src/nixos.rs
@@ -16,6 +16,7 @@ use crate::{
DiffType,
OsBuildVmArgs,
OsGenerationsArgs,
+ OsRebuildActivateArgs,
OsRebuildArgs,
OsReplArgs,
OsRollbackArgs,
@@ -35,14 +36,20 @@ impl interface::OsArgs {
pub fn run(self, elevation: ElevationStrategy) -> Result<()> {
use OsRebuildVariant::{Boot, Build, Switch, Test};
match self.subcommand {
- OsSubcommand::Boot(args) => args.rebuild(&Boot, None, elevation),
- OsSubcommand::Test(args) => args.rebuild(&Test, None, elevation),
- OsSubcommand::Switch(args) => args.rebuild(&Switch, None, elevation),
+ OsSubcommand::Boot(args) => {
+ args.rebuild_and_activate(&Boot, None, elevation)
+ },
+ OsSubcommand::Test(args) => {
+ args.rebuild_and_activate(&Test, None, elevation)
+ },
+ OsSubcommand::Switch(args) => {
+ args.rebuild_and_activate(&Switch, None, elevation)
+ },
OsSubcommand::Build(args) => {
if args.common.ask || args.common.dry {
warn!("`--ask` and `--dry` have no effect for `nh os build`");
}
- args.rebuild(&Build, None, elevation)
+ args.build_only(&Build, None, elevation)
},
OsSubcommand::BuildVm(args) => args.build_vm(elevation),
OsSubcommand::Repl(args) => args.run(),
@@ -76,9 +83,21 @@ impl OsBuildVmArgs {
.unwrap_or_else(|| PathBuf::from("result"));
debug!("Building VM with attribute: {}", attr);
- self
- .common
- .rebuild(&OsRebuildVariant::BuildVm, Some(&attr), elevation)?;
+
+ // Show warning if no hostname was explicitly provided for VM builds
+ if self.common.hostname.is_none() {
+ let (_, target_hostname) = self.common.setup_build_context()?;
+ tracing::warn!(
+ "Guessing system is {target_hostname} for a VM image. If this isn't \
+ intended, use --hostname to change."
+ );
+ }
+
+ self.common.build_only(
+ &OsRebuildVariant::BuildVm,
+ Some(&attr),
+ elevation,
+ )?;
// If --run flag is set, execute the VM
if self.run {
@@ -89,9 +108,9 @@ impl OsBuildVmArgs {
}
}
-impl OsRebuildArgs {
+impl OsRebuildActivateArgs {
// final_attr is the attribute of config.system.build.X to evaluate.
- fn rebuild(
+ fn rebuild_and_activate(
self,
variant: &OsRebuildVariant,
final_attr: Option<&String>,
@@ -99,45 +118,36 @@ impl OsRebuildArgs {
) -> Result<()> {
use OsRebuildVariant::{Build, BuildVm};
- let (elevate, target_hostname) = self.setup_build_context()?;
-
- // Only show the warning if we're explicitly building a VM
- // and no hostname was explicitly provided (--hostname was None)
- if self.hostname.is_none()
- && matches!(variant, OsRebuildVariant::BuildVm)
- && final_attr
- .is_some_and(|attr| attr == "vm" || attr == "vmWithBootLoader")
- {
- tracing::warn!(
- "Guessing system is {} for a VM image. If this isn't intended, use \
- --hostname to change.",
- target_hostname
- );
- }
+ let (elevate, target_hostname) = self.rebuild.setup_build_context()?;
- let (out_path, _tempdir_guard) = self.determine_output_path(variant)?;
+ let (out_path, _tempdir_guard) =
+ self.rebuild.determine_output_path(variant)?;
- let toplevel =
- self.resolve_installable_and_toplevel(&target_hostname, final_attr)?;
+ let toplevel = self
+ .rebuild
+ .resolve_installable_and_toplevel(&target_hostname, final_attr)?;
let message = match variant {
BuildVm => "Building NixOS VM image",
_ => "Building NixOS configuration",
};
- self.execute_build_command(toplevel, &out_path, message)?;
+ self
+ .rebuild
+ .execute_build_command(toplevel, &out_path, message)?;
- let target_profile = self.resolve_specialisation_and_profile(&out_path)?;
+ let target_profile =
+ self.rebuild.resolve_specialisation_and_profile(&out_path)?;
- self.handle_dix_diff(&target_profile);
+ self.rebuild.handle_dix_diff(&target_profile);
- if self.common.dry || matches!(variant, Build | BuildVm) {
- if self.common.ask {
+ if self.rebuild.common.dry || matches!(variant, Build | BuildVm) {
+ if self.rebuild.common.ask {
warn!("--ask has no effect as dry run was requested");
}
// For VM builds, print instructions on how to run the VM
- if matches!(variant, BuildVm) && !self.common.dry {
+ if matches!(variant, BuildVm) && !self.rebuild.common.dry {
print_vm_instructions(&out_path);
}
@@ -155,6 +165,134 @@ impl OsRebuildArgs {
Ok(())
}
+ fn activate_rebuilt_config(
+ &self,
+ variant: &OsRebuildVariant,
+ out_path: &Path,
+ target_profile: &Path,
+ elevate: bool,
+ elevation: ElevationStrategy,
+ ) -> Result<()> {
+ use OsRebuildVariant::{Boot, Switch, Test};
+
+ if self.rebuild.common.ask {
+ let confirmation = inquire::Confirm::new("Apply the config?")
+ .with_default(false)
+ .prompt()?;
+
+ if !confirmation {
+ bail!("User rejected the new config");
+ }
+ }
+
+ if let Some(target_host) = &self.rebuild.target_host {
+ Command::new("nix")
+ .args([
+ "copy",
+ "--to",
+ format!("ssh://{target_host}").as_str(),
+ match target_profile.to_str() {
+ Some(s) => s,
+ None => {
+ return Err(eyre!("target_profile path is not valid UTF-8"));
+ },
+ },
+ ])
+ .message("Copying configuration to target")
+ .with_required_env()
+ .run()?;
+ }
+
+ let switch_to_configuration = target_profile
+ .canonicalize()
+ .context("Failed to resolve output path")?
+ .join("bin")
+ .join("switch-to-configuration")
+ .canonicalize()
+ .context("Failed to resolve switch-to-configuration path")?;
+
+ if !switch_to_configuration.exists() {
+ return Err(missing_switch_to_configuration_error());
+ }
+
+ let canonical_out_path =
+ switch_to_configuration.to_str().ok_or_else(|| {
+ eyre!("switch-to-configuration path contains invalid UTF-8")
+ })?;
+
+ if let Test | Switch = variant {
+ let variant_label = match variant {
+ Test => "test",
+ Switch => "switch",
+ _ => unreachable!(),
+ };
+
+ Command::new(canonical_out_path)
+ .arg("test")
+ .ssh(self.rebuild.target_host.clone())
+ .message("Activating configuration")
+ .elevate(elevate.then_some(elevation.clone()))
+ .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"])
+ .with_required_env()
+ .show_output(self.show_systemctl_hints)
+ .run()
+ .map_err(|e| {
+ // Check if this looks like a service/unit failure
+ let error_display = format!("{e:#}");
+ let error_lower = error_display.to_lowercase();
+
+ let is_service_failure = error_lower.contains("units failed")
+ || (error_lower.contains("failed")
+ && error_lower.contains("service"))
+ || (error_lower.contains("failed") && error_lower.contains("unit"));
+
+ if is_service_failure && self.show_systemctl_hints {
+ e.wrap_err(format!(
+ "Activation ({variant_label}) failed\n\nTo investigate failed \
+ services:\n systemctl --failed\n journalctl -xe -u \
+ "
+ ))
+ } else {
+ e.wrap_err(format!("Activation ({variant_label}) failed"))
+ }
+ })?;
+
+ debug!("Completed {variant:?} operation with output path: {out_path:?}");
+ }
+
+ if let Boot | Switch = variant {
+ Command::new("nix")
+ .elevate(elevate.then_some(elevation.clone()))
+ .args(["build", "--no-link", "--profile", SYSTEM_PROFILE])
+ .arg(canonical_out_path)
+ .ssh(self.rebuild.target_host.clone())
+ .with_required_env()
+ .run()
+ .wrap_err("Failed to set system profile")?;
+
+ let mut cmd = Command::new(switch_to_configuration)
+ .arg("boot")
+ .ssh(self.rebuild.target_host.clone())
+ .elevate(elevate.then_some(elevation))
+ .message("Adding configuration to bootloader")
+ .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]);
+
+ if self.rebuild.install_bootloader {
+ cmd = cmd.set_env("NIXOS_INSTALL_BOOTLOADER", "1");
+ }
+
+ cmd
+ .with_required_env()
+ .run()
+ .wrap_err("Bootloader activation failed")?;
+ }
+
+ debug!("Completed {variant:?} operation with output path: {out_path:?}");
+ Ok(())
+ }
+}
+
+impl OsRebuildArgs {
/// Performs initial setup and gathers context for an OS rebuild operation.
///
/// This includes:
@@ -307,103 +445,37 @@ impl OsRebuildArgs {
}
}
- fn activate_rebuilt_config(
- &self,
+ // final_attr is the attribute of config.system.build.X to evaluate.
+ // Used by Build and BuildVm subcommands which don't activate
+ fn build_only(
+ self,
variant: &OsRebuildVariant,
- out_path: &Path,
- target_profile: &Path,
- elevate: bool,
- elevation: ElevationStrategy,
+ final_attr: Option<&String>,
+ _elevation: ElevationStrategy,
) -> Result<()> {
- use OsRebuildVariant::{Boot, Switch, Test};
-
- if self.common.ask {
- let confirmation = inquire::Confirm::new("Apply the config?")
- .with_default(false)
- .prompt()?;
-
- if !confirmation {
- bail!("User rejected the new config");
- }
- }
-
- if let Some(target_host) = &self.target_host {
- Command::new("nix")
- .args([
- "copy",
- "--to",
- format!("ssh://{target_host}").as_str(),
- match target_profile.to_str() {
- Some(s) => s,
- None => {
- return Err(eyre!("target_profile path is not valid UTF-8"));
- },
- },
- ])
- .message("Copying configuration to target")
- .with_required_env()
- .run()?;
- }
-
- let switch_to_configuration = target_profile
- .canonicalize()
- .context("Failed to resolve output path")?
- .join("bin")
- .join("switch-to-configuration")
- .canonicalize()
- .context("Failed to resolve switch-to-configuration path")?;
+ use OsRebuildVariant::{Build, BuildVm};
- if !switch_to_configuration.exists() {
- return Err(missing_switch_to_configuration_error());
- }
+ let (_, target_hostname) = self.setup_build_context()?;
- let canonical_out_path =
- switch_to_configuration.to_str().ok_or_else(|| {
- eyre!("switch-to-configuration path contains invalid UTF-8")
- })?;
+ let (out_path, _tempdir_guard) = self.determine_output_path(variant)?;
- if let Test | Switch = variant {
- Command::new(canonical_out_path)
- .arg("test")
- .ssh(self.target_host.clone())
- .message("Activating configuration")
- .elevate(elevate.then_some(elevation.clone()))
- .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"])
- .with_required_env()
- .run()
- .wrap_err("Activation (test) failed")?;
+ let toplevel =
+ self.resolve_installable_and_toplevel(&target_hostname, final_attr)?;
- debug!("Completed {variant:?} operation with output path: {out_path:?}");
- }
+ let message = match variant {
+ BuildVm => "Building NixOS VM image",
+ _ => "Building NixOS configuration",
+ };
- if let Boot | Switch = variant {
- Command::new("nix")
- .elevate(elevate.then_some(elevation.clone()))
- .args(["build", "--no-link", "--profile", SYSTEM_PROFILE])
- .arg(canonical_out_path)
- .ssh(self.target_host.clone())
- .with_required_env()
- .run()
- .wrap_err("Failed to set system profile")?;
+ self.execute_build_command(toplevel, &out_path, message)?;
- let mut cmd = Command::new(switch_to_configuration)
- .arg("boot")
- .ssh(self.target_host.clone())
- .elevate(elevate.then_some(elevation))
- .message("Adding configuration to bootloader")
- .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]);
+ let target_profile = self.resolve_specialisation_and_profile(&out_path)?;
- if self.install_bootloader {
- cmd = cmd.set_env("NIXOS_INSTALL_BOOTLOADER", "1");
- }
+ self.handle_dix_diff(&target_profile);
- cmd
- .with_required_env()
- .run()
- .wrap_err("Bootloader activation failed")?;
- }
+ // Build and BuildVm subcommands never activate
+ debug_assert!(matches!(variant, Build | BuildVm));
- debug!("Completed {variant:?} operation with output path: {out_path:?}");
Ok(())
}
}
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 09efafd5..6633dc93 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,7 +5,9 @@ edition.workspace = true
publish = false
[dependencies]
-clap.workspace = true
-clap_mangen = "0.2.28"
-nh = { path = "../." }
-roff = "0.2.2"
+clap.workspace = true
+clap_complete = "4.5.61"
+clap_complete_nushell = "4.5.10"
+clap_mangen = "0.2.31"
+nh = { path = "../." }
+roff = "0.2.2"
diff --git a/xtask/src/comp.rs b/xtask/src/comp.rs
new file mode 100644
index 00000000..8046dac6
--- /dev/null
+++ b/xtask/src/comp.rs
@@ -0,0 +1,100 @@
+use std::path::{Path, PathBuf};
+
+use clap::{CommandFactory, ValueEnum};
+use clap_complete::generate_to;
+
+const BINARY_NAME: &str = "nh";
+
+#[derive(Copy, Clone, Debug, ValueEnum)]
+pub enum CompletionShell {
+ Bash,
+ Elvish,
+ Fish,
+ PowerShell,
+ Zsh,
+ Nushell,
+}
+
+impl std::fmt::Display for CompletionShell {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Bash => write!(f, "Bash"),
+ Self::Elvish => write!(f, "Elvish"),
+ Self::Fish => write!(f, "Fish"),
+ Self::PowerShell => write!(f, "PowerShell"),
+ Self::Zsh => write!(f, "Zsh"),
+ Self::Nushell => write!(f, "Nushell"),
+ }
+ }
+}
+
+const ALL_SHELLS: [CompletionShell; 6] = [
+ CompletionShell::Bash,
+ CompletionShell::Elvish,
+ CompletionShell::Fish,
+ CompletionShell::PowerShell,
+ CompletionShell::Zsh,
+ CompletionShell::Nushell,
+];
+
+pub fn generate(
+ out_dir: &str,
+ shell: Option,
+) -> Result<(), String> {
+ let gen_dir = Path::new(out_dir);
+ if !gen_dir.exists() {
+ std::fs::create_dir_all(gen_dir).map_err(|e| {
+ format!("Failed to create output directory '{}': {}", out_dir, e)
+ })?;
+ }
+
+ let mut cmd = nh::interface::Main::command();
+
+ match shell {
+ Some(shell) => {
+ generate_single(shell, &mut cmd, gen_dir)?;
+ println!("Generated {} completion to {}", shell, out_dir);
+ },
+ None => {
+ for shell in ALL_SHELLS {
+ generate_single(shell, &mut cmd, gen_dir)?;
+ }
+ println!("Generated all completions to {}", out_dir);
+ },
+ }
+
+ Ok(())
+}
+
+fn generate_single(
+ shell: CompletionShell,
+ cmd: &mut clap::Command,
+ out_dir: &Path,
+) -> Result {
+ match shell {
+ CompletionShell::Bash => {
+ generate_to(clap_complete::Shell::Bash, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate Bash completion: {}", e))
+ },
+ CompletionShell::Elvish => {
+ generate_to(clap_complete::Shell::Elvish, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate Elvish completion: {}", e))
+ },
+ CompletionShell::Fish => {
+ generate_to(clap_complete::Shell::Fish, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate Fish completion: {}", e))
+ },
+ CompletionShell::PowerShell => {
+ generate_to(clap_complete::Shell::PowerShell, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate PowerShell completion: {}", e))
+ },
+ CompletionShell::Zsh => {
+ generate_to(clap_complete::Shell::Zsh, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate Zsh completion: {}", e))
+ },
+ CompletionShell::Nushell => {
+ generate_to(clap_complete_nushell::Nushell, cmd, BINARY_NAME, out_dir)
+ .map_err(|e| format!("Failed to generate Nushell completion: {}", e))
+ },
+ }
+}
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 0e04778b..bf4aaa40 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -1,12 +1,16 @@
use std::{
env,
path::{Path, PathBuf},
+ process,
};
use clap::{Parser, Subcommand};
+mod comp;
mod man;
+use comp::CompletionShell;
+
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
@@ -18,16 +22,48 @@ enum Command {
/// Generate manpage
Man {
/// Output directory for manpage
- #[arg(long, default_value = "gen")]
+ #[arg(long, default_value = "man")]
+ out_dir: String,
+ },
+ /// Generate shell completions
+ Completions {
+ /// Output directory for completions
+ #[arg(long, default_value = "comp")]
out_dir: String,
+ /// Shell to generate completions for (generates all if not specified)
+ #[arg(value_enum)]
+ shell: Option,
},
+ /// Generate both manpages and completions
+ Dist,
}
-fn main() {
+fn main() -> Result<(), Box> {
let Cli { command } = Cli::parse();
- env::set_current_dir(project_root()).unwrap();
+
+ env::set_current_dir(project_root())?;
+
match command {
- Command::Man { out_dir } => man::r#gen(&out_dir),
+ Command::Man { out_dir } => man::generate(&out_dir).map_err(|e| e.into()),
+ Command::Completions { out_dir, shell } => {
+ comp::generate(&out_dir, shell).map_err(|e| e.into())
+ },
+ Command::Dist => {
+ let man_handle = std::thread::spawn(|| man::generate("man"));
+ let comp_handle = std::thread::spawn(|| comp::generate("comp", None));
+
+ let man_result = man_handle
+ .join()
+ .map_err(|_| "man thread panicked".to_string())?;
+ let comp_result = comp_handle
+ .join()
+ .map_err(|_| "comp thread panicked".to_string())?;
+
+ match (man_result, comp_result) {
+ (Ok(()), Ok(())) => Ok(()),
+ (Err(e), _) | (_, Err(e)) => Err(e.into()),
+ }
+ },
}
}
diff --git a/xtask/src/man.rs b/xtask/src/man.rs
index 73552cb7..92f9b9e3 100644
--- a/xtask/src/man.rs
+++ b/xtask/src/man.rs
@@ -3,28 +3,35 @@ use std::path::Path;
use clap::CommandFactory;
use roff::{Roff, bold, roman};
-pub fn r#gen(out_dir: &str) {
+pub fn generate(out_dir: &str) -> Result<(), String> {
let gen_dir = Path::new(out_dir);
if !gen_dir.exists() {
- std::fs::create_dir_all(gen_dir)
- .expect("failed to create output directory");
+ std::fs::create_dir_all(gen_dir).map_err(|e| {
+ format!("Failed to create output directory '{}': {}", out_dir, e)
+ })?;
}
- gen_man(gen_dir);
-}
-fn gen_man(base_dir: &Path) {
- let man_path = base_dir.join("nh.1");
+ let man_path = gen_dir.join("nh.1");
let mut buffer: Vec = Vec::new();
let mut cmd = nh::interface::Main::command();
let mut man = clap_mangen::Man::new(cmd.clone());
man = man.manual("nh manual".to_string());
- man.render_title(&mut buffer).unwrap();
- man.render_name_section(&mut buffer).unwrap();
- man.render_synopsis_section(&mut buffer).unwrap();
- man.render_description_section(&mut buffer).unwrap();
- render_command_recursive(&mut cmd, 1, &mut buffer);
+ man
+ .render_title(&mut buffer)
+ .map_err(|e| format!("Failed to render title: {}", e))?;
+ man
+ .render_name_section(&mut buffer)
+ .map_err(|e| format!("Failed to render name section: {}", e))?;
+ man
+ .render_synopsis_section(&mut buffer)
+ .map_err(|e| format!("Failed to render synopsis section: {}", e))?;
+ man
+ .render_description_section(&mut buffer)
+ .map_err(|e| format!("Failed to render description section: {}", e))?;
+ render_command_recursive(&mut cmd, 1, &mut buffer)?;
+ // EXIT STATUS section
let statuses = [
("0", "Successful program execution."),
("1", "Unsuccessful program execution."),
@@ -35,74 +42,69 @@ fn gen_man(base_dir: &Path) {
for (code, reason) in statuses {
sect.control("IP", [code]).text([roman(reason)]);
}
- sect.to_writer(&mut buffer).unwrap();
+ sect
+ .to_writer(&mut buffer)
+ .map_err(|e| format!("Failed to write exit status section: {}", e))?;
// EXAMPLES section
let examples = [
(
"Switch to a new NixOS configuration",
"nh os switch --hostname myhost --specialisation dev",
- "",
),
(
"Rollback to a previous NixOS generation",
"nh os rollback --to 42",
- "",
),
(
"Switch to a home-manager configuration",
"nh home switch --configuration alice@work",
- "",
),
(
"Build a home-manager configuration with backup",
"nh home build --backup-extension .bak",
- "",
),
(
"Switch to a darwin configuration",
"nh darwin switch --hostname mymac",
- "",
),
- ("Search for ripgrep", "nh search ripgrep", ""),
+ ("Search for ripgrep", "nh search ripgrep"),
(
"Show supported platforms for a package",
"nh search --platforms ripgrep",
- "",
- ),
- (
- "Clean all but keep 5 generations",
- "nh clean all --keep 5",
- "",
),
+ ("Clean all but keep 5 generations", "nh clean all --keep 5"),
(
"Clean a specific profile",
"nh clean profile /nix/var/nix/profiles/system",
- "",
),
];
let mut sect = Roff::new();
sect.control("SH", ["EXAMPLES"]);
- for (desc, command, result) in examples {
+ for (desc, command) in examples {
sect
.control("TP", [])
.text([roman(desc)])
.text([bold(format!("$ {}", command))])
.control("br", []);
- if !result.is_empty() {
- sect.text([roman(result)]);
- }
}
- sect.to_writer(&mut buffer).unwrap();
+ sect
+ .to_writer(&mut buffer)
+ .map_err(|e| format!("Failed to write examples section: {}", e))?;
+
+ std::fs::write(&man_path, buffer).map_err(|e| {
+ format!("Failed to write manpage to '{}': {}", man_path.display(), e)
+ })?;
- std::fs::write(man_path, buffer).expect("failed to write manpage");
+ println!("Generated manpage to {}", out_dir);
+ Ok(())
}
fn render_command_recursive(
cmd: &mut clap::Command,
depth: usize,
buffer: &mut Vec,
-) {
+) -> Result<(), String> {
let mut sect = Roff::new();
// Section header
@@ -155,10 +157,14 @@ fn render_command_recursive(
}
}
- sect.to_writer(buffer).unwrap();
+ sect
+ .to_writer(buffer)
+ .map_err(|e| format!("Failed to render command section: {}", e))?;
// Subcommands
for sub in cmd.get_subcommands_mut() {
- render_command_recursive(sub, depth + 1, buffer);
+ render_command_recursive(sub, depth + 1, buffer)?;
}
+
+ Ok(())
}