Compare commits

..

10 commits

Author SHA1 Message Date
6f9c6893e1
chore: bump deps; set MSRV to 1.90
Some checks are pending
Rust / build (push) Waiting to run
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5947bb6da4c5ab6b7c02222a2b0a4ac36a6a6964
2026-01-30 22:04:08 +03:00
5dc7b1dcd4
eh: add eh update or nu in symlink
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iee1d7c2ed2c4b2cd5520c68ceb2b5e6d6a6a6964
2026-01-30 22:04:07 +03:00
045d1632cb
nix: bump flake inputs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iec3db09400f92453b5ffb52e364852936a6a6964
2026-01-30 22:04:06 +03:00
e6d1b90b97
eh: improve error and warning glyphs; move logger to new crate
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I563b37a9f38f8dcec6dda7693ae45e826a6a6964
2026-01-30 22:04:05 +03:00
be3226bc3a
eh: improve error handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I13d7d14ed4de1ee472aae9fb4ec7ffe46a6a6964
2026-01-30 22:04:04 +03:00
9b632788c2
eh: rewrite command exec with thread-based pipe reading
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id0e34e109a6423820e24676968e08dc66a6a6964
2026-01-30 22:04:03 +03:00
304a7e1a1a
eh: remove unused tracing dep
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idd2818fa3d590b192c1bdecefb25da066a6a6964
2026-01-30 22:04:02 +03:00
4355f1d2c7
meta: move xtask to crates/
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8080e8d293726fad569f3f8dd79b22ea6a6a6964
2026-01-30 22:04:01 +03:00
237bfec0d4
chore: bump crate version
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3787eb5f42c471dda268e1f3ccffd0296a6a6964
2026-01-30 22:04:00 +03:00
6224f2f2d6
eh: attempt to prevent resource leaks
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I28d716bd37d17dd96731c7863b3383416a6a6964
2026-01-30 22:03:59 +03:00
16 changed files with 1482 additions and 617 deletions

281
Cargo.lock generated
View file

@ -4,18 +4,18 @@ version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstyle"
version = "1.0.11"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "bitflags"
@ -25,15 +25,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "cfg-if"
version = "1.0.1"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.51"
version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
dependencies = [
"clap_builder",
"clap_derive",
@ -41,9 +41,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.51"
version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
dependencies = [
"anstyle",
"clap_lex",
@ -51,18 +51,18 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.5.60"
version = "4.5.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971"
checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@ -72,24 +72,61 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.5"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "console"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys",
]
[[package]]
name = "dialoguer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
dependencies = [
"console",
"shell-words",
]
[[package]]
name = "eh"
version = "0.1.2"
version = "0.1.4"
dependencies = [
"clap",
"dialoguer",
"eh-log",
"regex",
"serde_json",
"tempfile",
"thiserror",
"tracing",
"tracing-subscriber",
"walkdir",
"yansi",
]
[[package]]
name = "eh-log"
version = "0.1.4"
dependencies = [
"yansi",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "errno"
version = "0.3.14"
@ -125,16 +162,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "lazy_static"
version = "1.5.0"
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
@ -142,26 +179,11 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "memchr"
version = "2.7.5"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "once_cell"
@ -169,26 +191,20 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "proc-macro2"
version = "1.0.95"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
@ -224,15 +240,15 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.5"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rustix"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
@ -251,25 +267,58 @@ dependencies = [
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"lazy_static",
"serde_core",
]
[[package]]
name = "smallvec"
version = "1.15.1"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "syn"
version = "2.0.104"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
@ -278,9 +327,9 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom",
@ -291,101 +340,35 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tracing"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "valuable"
version = "0.1.1"
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "walkdir"
@ -399,9 +382,9 @@ dependencies = [
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
@ -432,13 +415,13 @@ dependencies = [
[[package]]
name = "wit-bindgen"
version = "0.46.0"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "xtask"
version = "0.1.2"
version = "0.1.4"
dependencies = [
"clap",
"clap_complete",
@ -450,3 +433,9 @@ name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zmij"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"

View file

@ -1,6 +1,7 @@
[workspace]
members = [ "eh", "xtask" ]
resolver = "3"
default-members = [ "eh" ]
members = [ "eh", "crates/*" ]
resolver = "3"
[workspace.package]
authors = [ "NotAShelf <raf@notashelf.dev>" ]
@ -8,19 +9,22 @@ description = "Ergonomic Nix CLI helper"
edition = "2024"
license = "MPL-2.0"
readme = true
rust-version = "1.89"
version = "0.1.2"
rust-version = "1.90"
version = "0.1.4"
[workspace.dependencies]
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" }
clap_complete = "4.5.60"
regex = "1.12.2"
tempfile = "3.23.0"
thiserror = "2.0.17"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
walkdir = "2.5.0"
yansi = "1.0.1"
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" }
clap_complete = "4.5.65"
dialoguer = { default-features = false, version = "0.12.0" }
regex = "1.12.2"
serde_json = "1.0.149"
tempfile = "3.24.0"
thiserror = "2.0.18"
walkdir = "2.5.0"
yansi = "1.0.1"
eh = { path = "./eh" }
eh-log = { path = "./crates/eh-log" }
[profile.release]
codegen-units = 1

10
crates/eh-log/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "eh-log"
description = "Styled logging for eh"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
yansi.workspace = true

31
crates/eh-log/src/lib.rs Normal file
View file

@ -0,0 +1,31 @@
use std::fmt;
use yansi::Paint;
pub fn info(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".green().bold());
}
pub fn warn(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".yellow().bold());
}
pub fn error(args: fmt::Arguments) {
eprintln!(" {} {args}", "!".red().bold());
}
pub fn hint(args: fmt::Arguments) {
eprintln!(" {} {args}", "~".yellow().dim());
}
#[macro_export]
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_error { ($($t:tt)*) => { $crate::error(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_hint { ($($t:tt)*) => { $crate::hint(format_args!($($t)*)) } }

View file

@ -11,4 +11,4 @@ publish = false
[dependencies]
clap.workspace = true
clap_complete.workspace = true
eh = { path = "../eh" }
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,11 +11,12 @@ crate-type = [ "lib" ]
name = "eh"
[dependencies]
clap.workspace = true
regex.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.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

@ -1,7 +1,9 @@
use std::{
collections::VecDeque,
io::{self, Read, Write},
process::{Command, ExitStatus, Output, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use crate::error::{EhError, Result};
@ -27,6 +29,43 @@ impl LogInterceptor for StdIoInterceptor {
/// Default buffer size for reading command output
const DEFAULT_BUFFER_SIZE: usize = 4096;
/// Default timeout for command execution
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
enum PipeEvent {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Error(io::Error),
}
/// Drain a pipe reader, sending chunks through the channel.
fn read_pipe<R: Read>(
mut reader: R,
tx: mpsc::Sender<PipeEvent>,
is_stderr: bool,
) {
let mut buf = [0u8; DEFAULT_BUFFER_SIZE];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let event = if is_stderr {
PipeEvent::Stderr(buf[..n].to_vec())
} else {
PipeEvent::Stdout(buf[..n].to_vec())
};
if tx.send(event).is_err() {
break;
}
},
Err(e) => {
let _ = tx.send(PipeEvent::Error(e));
break;
},
}
}
}
/// Builder and executor for Nix commands.
pub struct NixCommand {
subcommand: String,
@ -54,16 +93,6 @@ impl NixCommand {
self
}
#[allow(dead_code, reason = "FIXME")]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn args_ref(mut self, args: &[String]) -> Self {
self.args.extend(args.iter().cloned());
@ -97,11 +126,9 @@ impl NixCommand {
self
}
/// Run the command, streaming output to the provided interceptor.
pub fn run_with_logs<I: LogInterceptor + 'static>(
&self,
mut interceptor: I,
) -> Result<ExitStatus> {
/// Build the underlying `std::process::Command` with all configured
/// arguments, environment variables, and flags.
fn build_command(&self) -> Command {
let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand);
@ -117,6 +144,18 @@ impl NixCommand {
cmd.env(k, v);
}
cmd.args(&self.args);
cmd
}
/// Run the command, streaming output to the provided interceptor.
///
/// Stdout and stderr are read concurrently using background threads
/// so that neither pipe blocks the other.
pub fn run_with_logs<I: LogInterceptor + 'static>(
&self,
mut interceptor: I,
) -> Result<ExitStatus> {
let mut cmd = self.build_command();
if self.interactive {
cmd.stdout(Stdio::inherit());
@ -129,86 +168,152 @@ impl NixCommand {
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let child_stdout = child.stdout.take().ok_or_else(|| {
let stdout = child.stdout.take().ok_or_else(|| {
EhError::CommandFailed {
command: format!("nix {}", self.subcommand),
}
})?;
let child_stderr = child.stderr.take().ok_or_else(|| {
let stderr = child.stderr.take().ok_or_else(|| {
EhError::CommandFailed {
command: format!("nix {}", self.subcommand),
}
})?;
let mut stdout = child_stdout;
let mut stderr = child_stderr;
let mut out_buf = [0u8; DEFAULT_BUFFER_SIZE];
let mut err_buf = [0u8; DEFAULT_BUFFER_SIZE];
let (tx, rx) = mpsc::channel();
let mut out_queue = VecDeque::new();
let mut err_queue = VecDeque::new();
let tx_out = tx.clone();
let stdout_thread = thread::spawn(move || read_pipe(stdout, tx_out, false));
let tx_err = tx;
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx_err, true));
let start_time = Instant::now();
loop {
let mut did_something = false;
match stdout.read(&mut out_buf) {
Ok(0) => {},
Ok(n) => {
interceptor.on_stdout(&out_buf[..n]);
out_queue.push_back(Vec::from(&out_buf[..n]));
did_something = true;
},
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {},
Err(e) => return Err(EhError::Io(e)),
if start_time.elapsed() > DEFAULT_TIMEOUT {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait();
return Err(EhError::Timeout {
command: format!("nix {}", self.subcommand),
duration: DEFAULT_TIMEOUT,
});
}
match stderr.read(&mut err_buf) {
Ok(0) => {},
Ok(n) => {
interceptor.on_stderr(&err_buf[..n]);
err_queue.push_back(Vec::from(&err_buf[..n]));
did_something = true;
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data),
Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data),
Ok(PipeEvent::Error(e)) => {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait();
return Err(EhError::Io(e));
},
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {},
Err(e) => return Err(EhError::Io(e)),
}
if !did_something && child.try_wait()?.is_some() {
break;
Err(mpsc::RecvTimeoutError::Timeout) => {},
// All senders dropped — both reader threads finished
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let status = child.wait()?;
Ok(status)
}
/// Run the command and capture all output.
/// Run the command and capture all output (with timeout).
pub fn output(&self) -> Result<Output> {
let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand);
if self.print_build_logs
&& !self.args.iter().any(|a| a == "--no-build-output")
{
cmd.arg("--print-build-logs");
}
if self.impure {
cmd.arg("--impure");
}
for (k, v) in &self.env {
cmd.env(k, v);
}
cmd.args(&self.args);
let mut cmd = self.build_command();
if self.interactive {
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
cmd.stdin(Stdio::inherit());
} else {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
return Ok(cmd.output()?);
}
Ok(cmd.output()?)
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (tx, rx) = mpsc::channel();
let tx_out = tx.clone();
let stdout_thread = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut r) = stdout {
let _ = r.read_to_end(&mut buf);
}
let _ = tx_out.send((false, buf));
});
let tx_err = tx;
let stderr_thread = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut r) = stderr {
let _ = r.read_to_end(&mut buf);
}
let _ = tx_err.send((true, buf));
});
let start_time = Instant::now();
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let mut received = 0;
while received < 2 {
let remaining = DEFAULT_TIMEOUT
.checked_sub(start_time.elapsed())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait();
return Err(EhError::Timeout {
command: format!("nix {}", self.subcommand),
duration: DEFAULT_TIMEOUT,
});
}
match rx.recv_timeout(remaining) {
Ok((true, buf)) => {
stderr_buf = buf;
received += 1;
},
Ok((false, buf)) => {
stdout_buf = buf;
received += 1;
},
Err(mpsc::RecvTimeoutError::Timeout) => {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait();
return Err(EhError::Timeout {
command: format!("nix {}", self.subcommand),
duration: DEFAULT_TIMEOUT,
});
},
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let status = child.wait()?;
Ok(Output {
status,
stdout: stdout_buf,
stderr: stderr_buf,
})
}
}

View file

@ -1,36 +1,59 @@
use std::time::Duration;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EhError {
#[error("Nix command failed: {0}")]
NixCommandFailed(String),
#[error("nix {command} failed")]
NixCommandFailed { command: String },
#[error("IO error: {0}")]
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("Regex error: {0}")]
#[error("regex: {0}")]
Regex(#[from] regex::Error),
#[error("UTF-8 conversion error: {0}")]
#[error("utf-8 conversion: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("Hash extraction failed")]
HashExtractionFailed,
#[error("could not extract hash from nix output")]
HashExtractionFailed { stderr: String },
#[error("No Nix files found")]
#[error("no .nix files found in the current directory")]
NoNixFilesFound,
#[error("Failed to fix hash in file: {path}")]
#[error("could not update hash in {path}")]
HashFixFailed { path: String },
#[error("Process exited with code: {code}")]
#[error("process exited with code {code}")]
ProcessExit { code: i32 },
#[error("Command execution failed: {command}")]
#[error("command '{command}' failed")]
CommandFailed { command: String },
#[error("Invalid input: {input} - {reason}")]
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
Timeout {
command: String,
duration: Duration,
},
#[error("'{expression}' failed to evaluate: {stderr}")]
PreEvalFailed {
expression: String,
stderr: String,
},
#[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>;
@ -40,9 +63,209 @@ impl EhError {
pub const fn exit_code(&self) -> i32 {
match self {
Self::ProcessExit { code } => *code,
Self::NixCommandFailed(_) => 1,
Self::CommandFailed { .. } => 1,
_ => 1,
Self::NixCommandFailed { .. } => 2,
Self::CommandFailed { .. } => 3,
Self::HashExtractionFailed { .. } => 4,
Self::NoNixFilesFound => 5,
Self::HashFixFailed { .. } => 6,
Self::InvalidInput { .. } => 7,
Self::Io(_) => 8,
Self::Regex(_) => 9,
Self::Utf8(_) => 10,
Self::Timeout { .. } => 11,
Self::PreEvalFailed { .. } => 12,
Self::JsonParse { .. } => 13,
Self::NoFlakeInputs => 14,
Self::UpdateCancelled => 0,
}
}
#[must_use]
pub fn hint(&self) -> Option<&str> {
match self {
Self::NixCommandFailed { .. } => {
Some("run with --show-trace for more details")
},
Self::PreEvalFailed { .. } => {
Some("check that the expression exists and is spelled correctly")
},
Self::HashExtractionFailed { .. } => {
Some("nix reported a hash mismatch but the hash could not be parsed")
},
Self::NoNixFilesFound => {
Some("run this command from a directory containing .nix files")
},
Self::Timeout { .. } => {
Some(
"the command took too long; try a faster network or a smaller \
derivation",
)
},
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 { .. }
| Self::UpdateCancelled => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_codes() {
assert_eq!(
EhError::NixCommandFailed {
command: "build".into(),
}
.exit_code(),
2
);
assert_eq!(
EhError::CommandFailed {
command: "x".into(),
}
.exit_code(),
3
);
assert_eq!(
EhError::HashExtractionFailed {
stderr: String::new(),
}
.exit_code(),
4
);
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6);
assert_eq!(
EhError::InvalidInput {
input: "x".into(),
reason: "y".into(),
}
.exit_code(),
7
);
assert_eq!(
EhError::Timeout {
command: "build".into(),
duration: Duration::from_secs(300),
}
.exit_code(),
11
);
assert_eq!(
EhError::PreEvalFailed {
expression: "x".into(),
stderr: "y".into(),
}
.exit_code(),
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]
fn test_display_messages() {
let err = EhError::Timeout {
command: "build".into(),
duration: Duration::from_secs(300),
};
assert_eq!(err.to_string(), "nix build timed out after 300 seconds");
let err = EhError::PreEvalFailed {
expression: "nixpkgs#hello".into(),
stderr: "attribute not found".into(),
};
assert!(err.to_string().contains("nixpkgs#hello"));
assert!(err.to_string().contains("attribute not found"));
let err = EhError::HashExtractionFailed {
stderr: "some output".into(),
};
assert!(err.to_string().contains("could not extract hash"));
}
#[test]
fn test_hints() {
assert!(
EhError::PreEvalFailed {
expression: "x".into(),
stderr: "y".into(),
}
.hint()
.is_some()
);
assert!(
EhError::HashExtractionFailed {
stderr: String::new(),
}
.hint()
.is_some()
);
assert!(EhError::NoNixFilesFound.hint().is_some());
assert!(
EhError::Timeout {
command: "x".into(),
duration: Duration::from_secs(1),
}
.hint()
.is_some()
);
assert!(
EhError::InvalidInput {
input: "x".into(),
reason: "y".into(),
}
.hint()
.is_some()
);
// Variants with hints
assert!(
EhError::NixCommandFailed {
command: "build".into(),
}
.hint()
.is_some()
);
assert!(
EhError::JsonParse {
detail: "x".into(),
}
.hint()
.is_some()
);
assert!(EhError::NoFlakeInputs.hint().is_some());
// Variants without hints
assert!(
EhError::CommandFailed {
command: "x".into(),
}
.hint()
.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

@ -2,30 +2,30 @@ use std::{env, path::Path};
use eh::{Cli, Command, CommandFactory, Parser};
use error::Result;
use yansi::Paint;
mod build;
mod command;
mod error;
mod run;
mod shell;
mod update;
mod util;
fn main() {
let format = tracing_subscriber::fmt::format()
.with_level(true) // don't include levels in formatted output
.with_target(true) // don't include targets
.with_thread_ids(false) // include the thread ID of the current thread
.with_thread_names(false) // include the name of the current thread
.compact(); // use the `Compact` formatting style.
tracing_subscriber::fmt().event_format(format).init();
let result = run_app();
match result {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(e.exit_code());
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(code);
},
}
}
@ -36,43 +36,55 @@ fn dispatch_multicall(
args: std::env::Args,
) -> Option<Result<i32>> {
let rest: Vec<String> = args.collect();
// Validate arguments before processing
if let Err(e) = util::validate_nix_args(&rest) {
return Some(Err(e));
let subcommand = match app_name {
"nr" => "run",
"ns" => "shell",
"nb" => "build",
"nu" => "update",
_ => return None,
};
// Handle --help/-h/--version before forwarding to nix
if rest.iter().any(|a| a == "--help" || a == "-h") {
eprintln!(
"{}: shorthand for '{}'",
app_name.bold(),
format!("eh {subcommand}").bold()
);
eprintln!(" {} {app_name} [args...]", "usage:".green().bold());
eprintln!(
" All arguments are forwarded to '{}'.",
format!("nix {subcommand}").dim()
);
return Some(Ok(0));
}
if rest.iter().any(|a| a == "--version") {
eprintln!("{} (eh {})", app_name.bold(), env!("CARGO_PKG_VERSION"));
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;
match app_name {
"nr" => {
Some(run::handle_nix_run(
&rest,
&hash_extractor,
&fixer,
&classifier,
))
Some(match subcommand {
"run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier),
"shell" => {
shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier)
},
"ns" => {
Some(shell::handle_nix_shell(
&rest,
&hash_extractor,
&fixer,
&classifier,
))
"build" => {
build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier)
},
"nb" => {
Some(build::handle_nix_build(
&rest,
&hash_extractor,
&fixer,
&classifier,
))
},
_ => None,
}
// subcommand is assigned from the match on app_name above;
// only "run"/"shell"/"build" are possible values.
_ => unreachable!(),
})
}
fn run_app() -> Result<i32> {
@ -107,7 +119,9 @@ 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!();
Ok(0)

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)));
}
}

View file

@ -1,11 +1,12 @@
use std::{
io::{BufWriter, Write},
path::{Path, PathBuf},
sync::LazyLock,
};
use eh_log::{log_info, log_warn};
use regex::Regex;
use tempfile::NamedTempFile;
use tracing::{info, warn};
use walkdir::WalkDir;
use yansi::Paint;
@ -14,22 +15,41 @@ use crate::{
error::{EhError, Result},
};
/// Compiled regex patterns for extracting the actual hash from nix stderr.
static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
[
Regex::new(r"got:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
Regex::new(r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
Regex::new(r"have:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
]
});
/// Compiled regex pattern for extracting the old (specified) hash from nix
/// stderr.
static HASH_OLD_EXTRACT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap()
});
/// Compiled regex patterns for matching hash attributes in .nix files.
static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
[
Regex::new(r#"hash\s*=\s*"[^"]*""#).unwrap(),
Regex::new(r#"sha256\s*=\s*"[^"]*""#).unwrap(),
Regex::new(r#"outputHash\s*=\s*"[^"]*""#).unwrap(),
]
});
pub trait HashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String>;
fn extract_old_hash(&self, stderr: &str) -> Option<String>;
}
pub struct RegexHashExtractor;
impl HashExtractor for RegexHashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String> {
let patterns = [
r"got:\s+(sha256-[a-zA-Z0-9+/=]+)",
r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)",
r"have:\s+(sha256-[a-zA-Z0-9+/=]+)",
];
for pattern in &patterns {
if let Ok(re) = Regex::new(pattern)
&& let Some(captures) = re.captures(stderr)
for re in HASH_EXTRACT_PATTERNS.iter() {
if let Some(captures) = re.captures(stderr)
&& let Some(hash) = captures.get(1)
{
return Some(hash.as_str().to_string());
@ -37,23 +57,43 @@ impl HashExtractor for RegexHashExtractor {
}
None
}
fn extract_old_hash(&self, stderr: &str) -> Option<String> {
HASH_OLD_EXTRACT_PATTERN
.captures(stderr)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
}
pub trait NixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool>;
fn fix_hash_in_files(
&self,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool>;
fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool>;
fn fix_hash_in_file(
&self,
file_path: &Path,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool>;
}
pub struct DefaultNixFileFixer;
impl NixFileFixer for DefaultNixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool> {
fn fix_hash_in_files(
&self,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool> {
let nix_files = self.find_nix_files()?;
let mut fixed = false;
for file_path in nix_files {
if self.fix_hash_in_file(&file_path, new_hash)? {
println!("Updated hash in {}", file_path.display());
if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
log_info!("updated hash in {}", file_path.display().bold());
fixed = true;
}
}
@ -61,8 +101,20 @@ impl NixFileFixer for DefaultNixFileFixer {
}
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
let should_skip = |entry: &walkdir::DirEntry| -> bool {
// Never skip the root entry, otherwise the entire walk is empty
if entry.depth() == 0 || !entry.file_type().is_dir() {
return false;
}
let name = entry.file_name().to_string_lossy();
name.starts_with('.')
|| matches!(name.as_ref(), "node_modules" | "target" | "result")
};
let files: Vec<PathBuf> = WalkDir::new(".")
.max_depth(3)
.into_iter()
.filter_entry(|e| !should_skip(e))
.filter_map(std::result::Result::ok)
.filter(|entry| {
entry.file_type().is_file()
@ -80,39 +132,59 @@ impl NixFileFixer for DefaultNixFileFixer {
Ok(files)
}
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> {
// Pre-compile regex patterns once to avoid repeated compilation
let patterns: Vec<(Regex, String)> = [
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)),
(
r#"sha256\s*=\s*"[^"]*""#,
format!(r#"sha256 = "{new_hash}""#),
),
(
r#"outputHash\s*=\s*"[^"]*""#,
format!(r#"outputHash = "{new_hash}""#),
),
]
.into_iter()
.map(|(pattern, replacement)| {
Regex::new(pattern)
.map(|re| (re, replacement))
.map_err(EhError::Regex)
})
.collect::<Result<Vec<_>>>()?;
fn fix_hash_in_file(
&self,
file_path: &Path,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool> {
// Read the entire file content
let content = std::fs::read_to_string(file_path)?;
let mut replaced = false;
let mut result_content = content;
// Apply replacements
for (re, replacement) in &patterns {
if re.is_match(&result_content) {
result_content = re
.replace_all(&result_content, replacement.as_str())
.into_owned();
replaced = true;
if let Some(old) = old_hash {
// Targeted replacement: only replace attributes whose value matches the
// old hash. Uses regexes to handle variable whitespace around `=`.
let old_escaped = regex::escape(old);
let targeted_patterns = [
(
Regex::new(&format!(r#"hash\s*=\s*"{old_escaped}""#)).unwrap(),
format!(r#"hash = "{new_hash}""#),
),
(
Regex::new(&format!(r#"sha256\s*=\s*"{old_escaped}""#)).unwrap(),
format!(r#"sha256 = "{new_hash}""#),
),
(
Regex::new(&format!(r#"outputHash\s*=\s*"{old_escaped}""#)).unwrap(),
format!(r#"outputHash = "{new_hash}""#),
),
];
for (re, replacement) in &targeted_patterns {
if re.is_match(&result_content) {
result_content = re
.replace_all(&result_content, replacement.as_str())
.into_owned();
replaced = true;
}
}
} else {
// Fallback: replace all hash attributes (original behavior)
let replacements = [
format!(r#"hash = "{new_hash}""#),
format!(r#"sha256 = "{new_hash}""#),
format!(r#"outputHash = "{new_hash}""#),
];
for (re, replacement) in HASH_FIX_PATTERNS.iter().zip(&replacements) {
if re.is_match(&result_content) {
result_content = re
.replace_all(&result_content, replacement.as_str())
.into_owned();
replaced = true;
}
}
}
@ -140,38 +212,121 @@ pub trait NixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool;
}
/// Pre-evaluate expression to catch errors early
fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> {
/// Classifies what retry action should be taken based on nix stderr output.
#[derive(Debug, PartialEq, Eq)]
pub enum RetryAction {
AllowUnfree,
AllowInsecure,
AllowBroken,
None,
}
impl RetryAction {
/// Returns `(env_var, reason)` for this retry action,
/// or `None` if no retry is needed.
fn env_override(&self) -> Option<(&str, &str)> {
match self {
Self::AllowUnfree => {
Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license"))
},
Self::AllowInsecure => {
Some(("NIXPKGS_ALLOW_INSECURE", "has been marked as insecure"))
},
Self::AllowBroken => {
Some(("NIXPKGS_ALLOW_BROKEN", "has been marked as broken"))
},
Self::None => None,
}
}
}
/// Extract the package/expression name from args (first non-flag argument).
fn package_name(args: &[String]) -> &str {
args
.iter()
.find(|a| !a.starts_with('-'))
.map(String::as_str)
.unwrap_or("<unknown>")
}
/// Print a retry message with consistent formatting.
/// Format: ` -> <pkg>: <reason>, setting <ENV>=1`
fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
log_warn!(
"{}: {}, setting {}",
pkg.bold(),
reason,
format!("{env_var}=1").bold()
);
}
/// Classify stderr into a retry action.
pub fn classify_retry_action(stderr: &str) -> RetryAction {
if stderr.contains("has an unfree license") && stderr.contains("refusing") {
RetryAction::AllowUnfree
} else if stderr.contains("has been marked as insecure")
&& stderr.contains("refusing")
{
RetryAction::AllowInsecure
} else if stderr.contains("has been marked as broken")
&& stderr.contains("refusing")
{
RetryAction::AllowBroken
} else {
RetryAction::None
}
}
/// Returns true if stderr looks like a genuine hash mismatch error
/// (not just any mention of "hash" or "sha256").
fn is_hash_mismatch_error(stderr: &str) -> bool {
stderr.contains("hash mismatch")
|| (stderr.contains("specified:") && stderr.contains("got:"))
}
/// Pre-evaluate expression to catch errors early.
///
/// Returns a `RetryAction` if the evaluation fails with a retryable error
/// (unfree/insecure/broken), allowing the caller to retry with the right
/// environment variables without ever streaming the verbose nix error output.
fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
// Find flake references or expressions to evaluate
// Only take the first non-flag argument (the package/expression)
let eval_arg = args.iter().find(|arg| !arg.starts_with('-'));
let Some(eval_arg) = eval_arg else {
return Ok(true); // No expression to evaluate
return Ok(RetryAction::None); // No expression to evaluate
};
let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw");
let eval_cmd = NixCommand::new("eval")
.arg(eval_arg)
.print_build_logs(false);
let output = eval_cmd.output()?;
if output.status.success() {
return Ok(true);
return Ok(RetryAction::None);
}
let stderr = String::from_utf8_lossy(&output.stderr);
// If eval fails due to unfree/insecure/broken, don't fail pre-evaluation
// Let the main command handle it with retry logic
if stderr.contains("has an unfree license")
|| stderr.contains("refusing to evaluate")
|| stderr.contains("has been marked as insecure")
|| stderr.contains("has been marked as broken")
{
return Ok(true);
// Classify whether this is a retryable error (unfree/insecure/broken)
let action = classify_retry_action(&stderr);
if action != RetryAction::None {
return Ok(action);
}
// For other eval failures, fail early
Ok(false)
// Non-retryable eval failure — fail fast with a clear message
// rather than running the full command and showing the same error again.
let stderr_clean = stderr
.trim()
.strip_prefix("error:")
.unwrap_or(stderr.trim())
.trim();
Err(EhError::PreEvalFailed {
expression: eval_arg.clone(),
stderr: stderr_clean.to_string(),
})
}
pub fn validate_nix_args(args: &[String]) -> Result<()> {
@ -202,15 +357,28 @@ pub fn handle_nix_with_retry(
interactive: bool,
) -> Result<i32> {
validate_nix_args(args)?;
// Pre-evaluate for build commands to catch errors early
if !pre_evaluate(subcommand, args)? {
return Err(EhError::NixCommandFailed(
"Expression evaluation failed".to_string(),
));
// Pre-evaluate to detect retryable errors (unfree/insecure/broken) before
// running the actual command. This avoids streaming verbose nix error output
// only to retry immediately after.
let pkg = package_name(args);
let pre_eval_action = pre_evaluate(args)?;
if let Some((env_var, reason)) = pre_eval_action.env_override() {
print_retry_msg(pkg, reason, env_var);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.env(env_var, "1")
.impure(true);
if interactive {
retry_cmd = retry_cmd.interactive(true);
}
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1));
}
// For run commands, try interactive first to avoid breaking terminal
if subcommand == "run" && interactive {
// For run/shell commands, try interactive mode now that pre-eval passed
if interactive {
let status = NixCommand::new(subcommand)
.print_build_logs(true)
.interactive(true)
@ -221,18 +389,22 @@ pub fn handle_nix_with_retry(
}
}
// First, always capture output to check for errors that need retry
// Capture output to check for errors that need retry (hash mismatches etc.)
let output_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args);
let output = output_cmd.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
// Check if we need to retry with special flags
// Check for hash mismatch errors
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
match fixer.fix_hash_in_files(&new_hash) {
let old_hash = hash_extractor.extract_old_hash(&stderr);
match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) {
Ok(true) => {
info!("{}", Paint::green("✔ Fixed hash mismatch, retrying..."));
log_info!(
"{}: hash mismatch corrected in local files, rebuilding",
pkg.bold()
);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args);
@ -246,72 +418,33 @@ pub fn handle_nix_with_retry(
// No files were fixed, continue with normal error handling
},
Err(EhError::NoNixFilesFound) => {
warn!("No .nix files found to fix hash in");
log_warn!(
"{}: hash mismatch detected but no .nix files found to update",
pkg.bold()
);
// Continue with normal error handling
},
Err(e) => {
return Err(e);
},
}
} else if stderr.contains("hash") || stderr.contains("sha256") {
// If there's a hash-related error but we couldn't extract it, that's a
// failure
return Err(EhError::HashExtractionFailed);
} else if is_hash_mismatch_error(&stderr) {
// There's a genuine hash mismatch but we couldn't extract the new hash
return Err(EhError::HashExtractionFailed {
stderr: stderr.to_string(),
});
}
// Fallback: check for unfree/insecure/broken in captured output
// (in case pre_evaluate didn't catch it, e.g. from a dependency)
if classifier.should_retry(&stderr) {
if stderr.contains("has an unfree license") && stderr.contains("refusing") {
warn!(
"{}",
Paint::yellow(
"⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..."
)
);
let action = classify_retry_action(&stderr);
if let Some((env_var, reason)) = action.env_override() {
print_retry_msg(pkg, reason, env_var);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.env("NIXPKGS_ALLOW_UNFREE", "1")
.impure(true);
if interactive {
retry_cmd = retry_cmd.interactive(true);
}
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1));
}
if stderr.contains("has been marked as insecure")
&& stderr.contains("refusing")
{
warn!(
"{}",
Paint::yellow(
"⚠ Insecure package detected, retrying with \
NIXPKGS_ALLOW_INSECURE=1..."
)
);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.env("NIXPKGS_ALLOW_INSECURE", "1")
.impure(true);
if interactive {
retry_cmd = retry_cmd.interactive(true);
}
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1));
}
if stderr.contains("has been marked as broken")
&& stderr.contains("refusing")
{
warn!(
"{}",
Paint::yellow(
"⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1..."
)
);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.env("NIXPKGS_ALLOW_BROKEN", "1")
.env(env_var, "1")
.impure(true);
if interactive {
retry_cmd = retry_cmd.interactive(true);
@ -330,22 +463,23 @@ pub fn handle_nix_with_retry(
std::io::stderr()
.write_all(&output.stderr)
.map_err(EhError::Io)?;
Err(EhError::ProcessExit {
code: output.status.code().unwrap_or(1),
})
match output.status.code() {
Some(code) => Err(EhError::ProcessExit { code }),
// No exit code means the process was killed by a signal
None => {
Err(EhError::NixCommandFailed {
command: subcommand.to_string(),
})
},
}
}
pub struct DefaultNixErrorClassifier;
impl NixErrorClassifier for DefaultNixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool {
RegexHashExtractor.extract_hash(stderr).is_some()
|| (stderr.contains("has an unfree license")
&& stderr.contains("refusing"))
|| (stderr.contains("has been marked as insecure")
&& stderr.contains("refusing"))
|| (stderr.contains("has been marked as broken")
&& stderr.contains("refusing"))
classify_retry_action(stderr) != RetryAction::None
}
}
@ -379,7 +513,7 @@ mod tests {
let fixer = DefaultNixFileFixer;
let result = fixer
.fix_hash_in_file(file_path, "sha256-newhash999")
.fix_hash_in_file(file_path, None, "sha256-newhash999")
.unwrap();
assert!(result, "Hash replacement should return true");
@ -413,7 +547,7 @@ mod tests {
// Test hash replacement
let fixer = DefaultNixFileFixer;
let result = fixer
.fix_hash_in_file(&file_path, "sha256-newhash999")
.fix_hash_in_file(&file_path, None, "sha256-newhash999")
.unwrap();
assert!(
@ -448,7 +582,7 @@ mod tests {
// Test that streaming can handle large files without memory issues
let fixer = DefaultNixFileFixer;
let result = fixer
.fix_hash_in_file(file_path, "sha256-newhash999")
.fix_hash_in_file(file_path, None, "sha256-newhash999")
.unwrap();
assert!(result, "Hash replacement should work for large files");
@ -483,7 +617,9 @@ mod tests {
// Test hash replacement
let fixer = DefaultNixFileFixer;
let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap();
let result = fixer
.fix_hash_in_file(file_path, None, "sha256-newhash")
.unwrap();
assert!(result, "Hash replacement should succeed");
@ -538,4 +674,233 @@ mod tests {
safe_args
);
}
#[test]
fn test_input_validation_empty_args() {
let result = validate_nix_args(&[]);
assert!(result.is_ok(), "Empty args should be accepted");
}
#[test]
fn test_hash_extraction_got_pattern() {
let stderr = "hash mismatch in fixed-output derivation\n specified: \
sha256-AAAA\n got: \
sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
let extractor = RegexHashExtractor;
let hash = extractor.extract_hash(stderr);
assert!(hash.is_some());
assert!(hash.unwrap().starts_with("sha256-"));
}
#[test]
fn test_hash_extraction_actual_pattern() {
let stderr = "hash mismatch\n actual: \
sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=";
let extractor = RegexHashExtractor;
let hash = extractor.extract_hash(stderr);
assert!(hash.is_some());
assert!(hash.unwrap().starts_with("sha256-"));
}
#[test]
fn test_hash_extraction_have_pattern() {
let stderr = "hash mismatch\n have: \
sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=";
let extractor = RegexHashExtractor;
let hash = extractor.extract_hash(stderr);
assert!(hash.is_some());
assert!(hash.unwrap().starts_with("sha256-"));
}
#[test]
fn test_hash_extraction_no_match() {
let stderr = "error: some other nix error without hashes";
let extractor = RegexHashExtractor;
assert!(extractor.extract_hash(stderr).is_none());
}
#[test]
fn test_hash_extraction_partial_match() {
// Contains "got:" but no sha256 hash
let stderr = "got: some-other-value";
let extractor = RegexHashExtractor;
assert!(extractor.extract_hash(stderr).is_none());
}
#[test]
fn test_false_positive_hash_detection() {
// Normal nix output mentioning "hash" or "sha256" without being a mismatch
let cases = [
"evaluating attribute 'sha256' of derivation 'hello'",
"building '/nix/store/hash-something.drv'",
"copying path '/nix/store/sha256-abcdef-hello'",
"this derivation has a hash attribute set",
];
for stderr in &cases {
assert!(
!is_hash_mismatch_error(stderr),
"Should not detect hash mismatch in: {stderr}"
);
}
}
#[test]
fn test_genuine_hash_mismatch_detection() {
assert!(is_hash_mismatch_error(
"hash mismatch in fixed-output derivation"
));
assert!(is_hash_mismatch_error(
"specified: sha256-AAAA\n got: sha256-BBBB"
));
}
#[test]
fn test_classify_retry_action_unfree() {
let stderr =
"error: Package 'foo' has an unfree license, refusing to evaluate.";
assert_eq!(classify_retry_action(stderr), RetryAction::AllowUnfree);
}
#[test]
fn test_classify_retry_action_insecure() {
let stderr =
"error: Package 'bar' has been marked as insecure, refusing to evaluate.";
assert_eq!(classify_retry_action(stderr), RetryAction::AllowInsecure);
}
#[test]
fn test_classify_retry_action_broken() {
let stderr =
"error: Package 'baz' has been marked as broken, refusing to evaluate.";
assert_eq!(classify_retry_action(stderr), RetryAction::AllowBroken);
}
#[test]
fn test_classify_retry_action_none() {
let stderr = "error: attribute 'nonexistent' not found";
assert_eq!(classify_retry_action(stderr), RetryAction::None);
}
#[test]
fn test_retry_action_env_overrides() {
let (var, reason) = RetryAction::AllowUnfree.env_override().unwrap();
assert_eq!(var, "NIXPKGS_ALLOW_UNFREE");
assert!(reason.contains("unfree"));
let (var, reason) = RetryAction::AllowInsecure.env_override().unwrap();
assert_eq!(var, "NIXPKGS_ALLOW_INSECURE");
assert!(reason.contains("insecure"));
let (var, reason) = RetryAction::AllowBroken.env_override().unwrap();
assert_eq!(var, "NIXPKGS_ALLOW_BROKEN");
assert!(reason.contains("broken"));
assert_eq!(RetryAction::None.env_override(), None);
}
#[test]
fn test_classifier_should_retry() {
let classifier = DefaultNixErrorClassifier;
assert!(
classifier.should_retry(
"Package 'x' has an unfree license, refusing to evaluate"
)
);
assert!(classifier.should_retry(
"Package 'x' has been marked as insecure, refusing to evaluate"
));
assert!(classifier.should_retry(
"Package 'x' has been marked as broken, refusing to evaluate"
));
assert!(!classifier.should_retry("error: attribute not found"));
}
#[test]
fn test_old_hash_extraction() {
let stderr =
"hash mismatch in fixed-output derivation\n specified: \
sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n got: \
sha256-BBBB=";
let extractor = RegexHashExtractor;
let old = extractor.extract_old_hash(stderr);
assert!(old.is_some());
assert_eq!(
old.unwrap(),
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
);
}
#[test]
fn test_old_hash_extraction_missing() {
let stderr = "hash mismatch\n got: sha256-BBBB=";
let extractor = RegexHashExtractor;
assert!(extractor.extract_old_hash(stderr).is_none());
}
#[test]
fn test_targeted_hash_replacement_only_matching() {
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path();
// File with two derivations, each with a different hash
let test_content = r#"{ pkgs }:
{
a = pkgs.fetchurl {
url = "https://example.com/a.tar.gz";
hash = "sha256-AAAA";
};
b = pkgs.fetchurl {
url = "https://example.com/b.tar.gz";
hash = "sha256-BBBB";
};
}"#;
let mut file = std::fs::File::create(file_path).unwrap();
file.write_all(test_content.as_bytes()).unwrap();
file.flush().unwrap();
let fixer = DefaultNixFileFixer;
// Only replace the hash matching "sha256-AAAA"
let result = fixer
.fix_hash_in_file(file_path, Some("sha256-AAAA"), "sha256-NEWW")
.unwrap();
assert!(result, "Targeted replacement should return true");
let updated = std::fs::read_to_string(file_path).unwrap();
assert!(
updated.contains(r#"hash = "sha256-NEWW""#),
"Matching hash should be replaced"
);
assert!(
updated.contains(r#"hash = "sha256-BBBB""#),
"Non-matching hash should be untouched"
);
}
#[test]
fn test_targeted_hash_replacement_no_match() {
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path();
let test_content = r#"{ hash = "sha256-XXXX"; }"#;
let mut file = std::fs::File::create(file_path).unwrap();
file.write_all(test_content.as_bytes()).unwrap();
file.flush().unwrap();
let fixer = DefaultNixFileFixer;
// old_hash doesn't match anything in the file
let result = fixer
.fix_hash_in_file(file_path, Some("sha256-NOMATCH"), "sha256-NEWW")
.unwrap();
assert!(!result, "Should return false when old hash doesn't match");
let updated = std::fs::read_to_string(file_path).unwrap();
assert!(
updated.contains("sha256-XXXX"),
"Original hash should be untouched"
);
}
}

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1762844143,
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
"lastModified": 1769461804,
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
"type": "github"
},
"original": {