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 +

nh

@@ -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 search showcase -

- -- `nh clean` - a re-implementation of `nix-collect-garbage` that also collects - gcroots. -

- nh clean showcase -

- -### Platform Specific Subcommands - -- `nh os` - reimplements `nixos-rebuild`[^1] with the addition of - - build-tree displays. - - diff of changes. - - confirmation. -

- nh os switch showcase -

- -- `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 search showcase +

+ +- `nh clean` - a re-implementation of `nix-collect-garbage` that also collects + gcroots. + +

+ nh clean showcase +

+ +### 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 os switch showcase +

+ +- `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(()) }