eh: add eh update or nu in symlink

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iee1d7c2ed2c4b2cd5520c68ceb2b5e6d6a6a6964
This commit is contained in:
raf 2026-01-30 20:54:29 +03:00
commit 5dc7b1dcd4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 188 additions and 15 deletions

View file

@ -11,4 +11,4 @@ publish = false
[dependencies]
clap.workspace = true
clap_complete.workspace = true
eh.workspace = true
eh.workspace = true

View file

@ -42,6 +42,7 @@ enum Binary {
Nr,
Ns,
Nb,
Nu,
}
impl Binary {
@ -50,6 +51,7 @@ impl Binary {
Self::Nr => "nr",
Self::Ns => "ns",
Self::Nb => "nb",
Self::Nu => "nu",
}
}
}
@ -90,7 +92,7 @@ fn create_multicall_binaries(
);
}
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb];
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu];
let bin_path = Path::new(bin_dir);
for binary in multicall_binaries {
@ -153,7 +155,7 @@ fn generate_completions(
println!("completion file generated: {}", completion_file.display());
// Create symlinks for multicall binaries
let multicall_names = ["nb", "nr", "ns"];
let multicall_names = ["nb", "nr", "ns", "nu"];
for name in &multicall_names {
let symlink_path = output_dir.join(format!("{name}.{shell}"));
if symlink_path.exists() {

View file

@ -11,10 +11,12 @@ crate-type = [ "lib" ]
name = "eh"
[dependencies]
clap.workspace = true
eh-log.workspace = true
regex.workspace = true
tempfile.workspace = true
thiserror.workspace = true
walkdir.workspace = true
yansi.workspace = true
clap.workspace = true
dialoguer.workspace = true
eh-log.workspace = true
regex.workspace = true
serde_json.workspace = true
tempfile.workspace = true
thiserror.workspace = true
walkdir.workspace = true
yansi.workspace = true

View file

@ -45,6 +45,15 @@ pub enum EhError {
#[error("invalid input '{input}': {reason}")]
InvalidInput { input: String, reason: String },
#[error("failed to parse JSON from nix output: {detail}")]
JsonParse { detail: String },
#[error("no flake inputs found in lock file")]
NoFlakeInputs,
#[error("no inputs selected")]
UpdateCancelled,
}
pub type Result<T> = std::result::Result<T, EhError>;
@ -65,6 +74,9 @@ impl EhError {
Self::Utf8(_) => 10,
Self::Timeout { .. } => 11,
Self::PreEvalFailed { .. } => 12,
Self::JsonParse { .. } => 13,
Self::NoFlakeInputs => 14,
Self::UpdateCancelled => 0,
}
}
@ -92,12 +104,19 @@ impl EhError {
Self::InvalidInput { .. } => {
Some("avoid shell metacharacters in nix arguments")
},
Self::JsonParse { .. } => {
Some("ensure 'nix flake metadata --json' produces valid output")
},
Self::NoFlakeInputs => {
Some("run this from a directory with a flake.lock that has inputs")
},
Self::Io(_)
| Self::Regex(_)
| Self::Utf8(_)
| Self::HashFixFailed { .. }
| Self::ProcessExit { .. }
| Self::CommandFailed { .. } => None,
| Self::CommandFailed { .. }
| Self::UpdateCancelled => None,
}
}
}
@ -156,6 +175,15 @@ mod tests {
12
);
assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42);
assert_eq!(
EhError::JsonParse {
detail: "x".into(),
}
.exit_code(),
13
);
assert_eq!(EhError::NoFlakeInputs.exit_code(), 14);
assert_eq!(EhError::UpdateCancelled.exit_code(), 0);
}
#[test]
@ -221,6 +249,14 @@ mod tests {
.hint()
.is_some()
);
assert!(
EhError::JsonParse {
detail: "x".into(),
}
.hint()
.is_some()
);
assert!(EhError::NoFlakeInputs.hint().is_some());
// Variants without hints
assert!(
EhError::CommandFailed {
@ -230,5 +266,6 @@ mod tests {
.is_none()
);
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
assert!(EhError::UpdateCancelled.hint().is_none());
}
}

View file

@ -3,6 +3,7 @@ pub mod command;
pub mod error;
pub mod run;
pub mod shell;
pub mod update;
pub mod util;
pub use clap::{CommandFactory, Parser, Subcommand};
@ -34,4 +35,9 @@ pub enum Command {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Update flake inputs interactively
Update {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
}

View file

@ -9,6 +9,7 @@ mod command;
mod error;
mod run;
mod shell;
mod update;
mod util;
fn main() {
@ -17,11 +18,14 @@ fn main() {
match result {
Ok(code) => std::process::exit(code),
Err(e) => {
eh_log::log_error!("{e}");
if let Some(hint) = e.hint() {
eh_log::log_hint!("{hint}");
let code = e.exit_code();
if code != 0 {
eh_log::log_error!("{e}");
if let Some(hint) = e.hint() {
eh_log::log_hint!("{hint}");
}
}
std::process::exit(e.exit_code());
std::process::exit(code);
},
}
}
@ -37,6 +41,7 @@ fn dispatch_multicall(
"nr" => "run",
"ns" => "shell",
"nb" => "build",
"nu" => "update",
_ => return None,
};
@ -60,6 +65,10 @@ fn dispatch_multicall(
return Some(Ok(0));
}
if subcommand == "update" {
return Some(update::handle_update(&rest));
}
let hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier;
@ -110,6 +119,8 @@ fn run_app() -> Result<i32> {
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier)
},
Some(Command::Update { args }) => update::handle_update(&args),
None => {
Cli::command().print_help()?;
println!();

115
eh/src/update.rs Normal file
View file

@ -0,0 +1,115 @@
use crate::{
command::{NixCommand, StdIoInterceptor},
error::{EhError, Result},
};
/// Parse flake input names from `nix flake metadata --json` output.
pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
let value: serde_json::Value =
serde_json::from_str(stdout).map_err(|e| EhError::JsonParse {
detail: e.to_string(),
})?;
let inputs = value
.get("locks")
.and_then(|l| l.get("nodes"))
.and_then(|n| n.get("root"))
.and_then(|r| r.get("inputs"))
.and_then(|i| i.as_object())
.ok_or(EhError::NoFlakeInputs)?;
let mut names: Vec<String> = inputs.keys().cloned().collect();
names.sort();
Ok(names)
}
/// Fetch flake input names by running `nix flake metadata --json`.
fn fetch_flake_inputs() -> Result<Vec<String>> {
let output = NixCommand::new("flake")
.arg("metadata")
.arg("--json")
.print_build_logs(false)
.output()?;
let stdout = String::from_utf8(output.stdout)?;
parse_flake_inputs(&stdout)
}
/// Prompt the user to select inputs via a multi-select dialog.
fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
let selections = dialoguer::MultiSelect::new()
.with_prompt("Select inputs to update")
.items(inputs)
.interact()
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
if selections.is_empty() {
return Err(EhError::UpdateCancelled);
}
Ok(selections.iter().map(|&i| inputs[i].clone()).collect())
}
/// Entry point for the `update` subcommand.
///
/// If `args` is non-empty, use them as explicit input names.
/// Otherwise, fetch inputs interactively and prompt for selection.
pub fn handle_update(args: &[String]) -> Result<i32> {
let selected = if args.is_empty() {
let inputs = fetch_flake_inputs()?;
if inputs.is_empty() {
return Err(EhError::NoFlakeInputs);
}
prompt_input_selection(&inputs)?
} else {
args.to_vec()
};
let mut cmd = NixCommand::new("flake").arg("lock");
for name in &selected {
cmd = cmd.arg("--update-input").arg(name);
}
eh_log::log_info!("updating inputs: {}", selected.join(", "));
let status = cmd.run_with_logs(StdIoInterceptor)?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_flake_inputs() {
let json = r#"{
"locks": {
"nodes": {
"root": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"home-manager": "home-manager_2",
"flake-utils": "flake-utils_2"
}
}
}
}
}"#;
let inputs = parse_flake_inputs(json).unwrap();
assert_eq!(inputs, vec!["flake-utils", "home-manager", "nixpkgs"]);
}
#[test]
fn test_parse_flake_inputs_invalid_json() {
let result = parse_flake_inputs("not json");
assert!(result.is_err());
}
#[test]
fn test_parse_flake_inputs_no_inputs() {
let json = r#"{"locks": {"nodes": {"root": {}}}}"#;
let result = parse_flake_inputs(json);
assert!(matches!(result, Err(EhError::NoFlakeInputs)));
}
}