git.delta.rocks / jrsonnet / refs/commits / 966e7f167d68

difftreelog

refactor c bindings

Yaroslav Bolyukin2024-05-25parent: #3f5aab0.patch.diff
in: trunk

26 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -198,9 +198,9 @@
 
 [[package]]
 name = "anyhow"
-version = "1.0.83"
+version = "1.0.86"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3"
+checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
 
 [[package]]
 name = "arc-swap"
@@ -222,7 +222,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -374,9 +374,9 @@
 
 [[package]]
 name = "cc"
-version = "1.0.97"
+version = "1.0.98"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4"
+checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f"
 
 [[package]]
 name = "cfg-if"
@@ -468,7 +468,7 @@
  "heck",
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -587,6 +587,7 @@
  "cfg-if",
  "cpufeatures",
  "curve25519-dalek-derive",
+ "digest",
  "fiat-crypto",
  "platforms",
  "rustc_version",
@@ -602,7 +603,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -658,14 +659,39 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
 ]
 
 [[package]]
 name = "either"
-version = "1.11.0"
+version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2"
+checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b"
 
 [[package]]
 name = "encode_unicode"
@@ -894,7 +920,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -928,6 +954,18 @@
 ]
 
 [[package]]
+name = "generator-helper"
+version = "0.1.0"
+dependencies = [
+ "age",
+ "anyhow",
+ "clap",
+ "ed25519-dalek",
+ "fleet-shared",
+ "rand",
+]
+
+[[package]]
 name = "generic-array"
 version = "0.14.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1033,7 +1071,7 @@
  "serde",
  "serde_derive",
  "thiserror",
- "toml 0.8.12",
+ "toml 0.8.13",
  "unic-langid",
 ]
 
@@ -1075,7 +1113,7 @@
  "proc-macro2",
  "quote",
  "strsim",
- "syn 2.0.63",
+ "syn 2.0.66",
  "unic-langid",
 ]
 
@@ -1089,7 +1127,7 @@
  "i18n-config",
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -1151,9 +1189,9 @@
 
 [[package]]
 name = "instant"
-version = "0.1.12"
+version = "0.1.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
 dependencies = [
  "cfg-if",
 ]
@@ -1241,9 +1279,9 @@
 
 [[package]]
 name = "libc"
-version = "0.2.154"
+version = "0.2.155"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
 
 [[package]]
 name = "libm"
@@ -1253,9 +1291,9 @@
 
 [[package]]
 name = "libmimalloc-sys"
-version = "0.1.37"
+version = "0.1.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81eb4061c0582dedea1cbc7aff2240300dd6982e0239d1c99e65c1dbf4a30ba7"
+checksum = "0e7bb23d733dfcc8af652a78b7bf232f0e967710d044732185e561e47c0336b6"
 dependencies = [
  "cc",
  "libc",
@@ -1269,9 +1307,9 @@
 
 [[package]]
 name = "linux-raw-sys"
-version = "0.4.13"
+version = "0.4.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
 
 [[package]]
 name = "lock_api"
@@ -1321,9 +1359,9 @@
 
 [[package]]
 name = "mimalloc"
-version = "0.1.41"
+version = "0.1.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f41a2280ded0da56c8cf898babb86e8f10651a34adcfff190ae9a1159c6908d"
+checksum = "e9186d86b79b52f4a77af65604b51225e8db1d6ee7e3f41aec1e40829c71a176"
 dependencies = [
  "libmimalloc-sys",
 ]
@@ -1336,9 +1374,9 @@
 
 [[package]]
 name = "miniz_oxide"
-version = "0.7.2"
+version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
+checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae"
 dependencies = [
  "adler",
 ]
@@ -1533,9 +1571,9 @@
 
 [[package]]
 name = "parking_lot"
-version = "0.12.2"
+version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
 dependencies = [
  "lock_api",
  "parking_lot_core",
@@ -1608,7 +1646,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -1717,9 +1755,9 @@
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.82"
+version = "1.0.84"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
+checksum = "ec96c6a92621310b51366f1e28d05ef11489516e93be030060e5fc12024a49d6"
 dependencies = [
  "unicode-ident",
 ]
@@ -1911,7 +1949,7 @@
  "proc-macro2",
  "quote",
  "rust-embed-utils",
- "syn 2.0.63",
+ "syn 2.0.66",
  "walkdir",
 ]
 
@@ -2041,9 +2079,9 @@
 
 [[package]]
 name = "serde"
-version = "1.0.202"
+version = "1.0.203"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
+checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
 dependencies = [
  "serde_derive",
 ]
@@ -2059,13 +2097,13 @@
 
 [[package]]
 name = "serde_derive"
-version = "1.0.202"
+version = "1.0.203"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
+checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -2081,9 +2119,9 @@
 
 [[package]]
 name = "serde_spanned"
-version = "0.6.5"
+version = "0.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
+checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
 dependencies = [
  "serde",
 ]
@@ -2245,9 +2283,9 @@
 
 [[package]]
 name = "syn"
-version = "2.0.63"
+version = "2.0.66"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704"
+checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2308,22 +2346,22 @@
 
 [[package]]
 name = "thiserror"
-version = "1.0.60"
+version = "1.0.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18"
+checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.60"
+version = "1.0.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524"
+checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -2401,7 +2439,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -2438,9 +2476,9 @@
 
 [[package]]
 name = "toml"
-version = "0.8.12"
+version = "0.8.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3"
+checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba"
 dependencies = [
  "serde",
  "serde_spanned",
@@ -2450,18 +2488,18 @@
 
 [[package]]
 name = "toml_datetime"
-version = "0.6.5"
+version = "0.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
+checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
 dependencies = [
  "serde",
 ]
 
 [[package]]
 name = "toml_edit"
-version = "0.22.12"
+version = "0.22.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef"
+checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c"
 dependencies = [
  "indexmap",
  "serde",
@@ -2489,7 +2527,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
 
 [[package]]
@@ -2708,7 +2746,7 @@
  "once_cell",
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
  "wasm-bindgen-shared",
 ]
 
@@ -2730,7 +2768,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
@@ -2964,5 +3002,5 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.63",
+ "syn 2.0.66",
 ]
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,27 @@
 [workspace.dependencies]
 nixlike = { path = "./crates/nixlike" }
 better-command = { path = "./crates/better-command" }
-bifrostlink = "0.1.0"
-uuid = { version = "1.7.0", features = ["v4"] }
-tokio = { version = "1.36.0", features = ["fs", "rt", "macros", "sync", "time", "rt-multi-thread"] }
 fleet-shared = { path = "./crates/fleet-shared" }
+tokio = { version = "1.36.0", features = [
+	"fs",
+	"rt",
+	"macros",
+	"sync",
+	"time",
+	"rt-multi-thread",
+] }
+# Using fixed version for rust on stable nixos branches.
+clap = { version = ">=4.4, <4.5", features = [
+	"derive",
+	"env",
+	"wrap_help",
+	"unicode",
+] }
+age = { version = "0.10", features = ["ssh"] }
+anyhow = "1.0"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+tempfile = "3.10"
+nix = {version = "0.27.1", features = ["user", "fs"]}
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -9,27 +9,21 @@
 nixlike.workspace = true
 better-command.workspace = true
 tokio.workspace = true
-anyhow = "1.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
+clap.workspace = true
+age = { workspace = true, features = ["armor"] }
+anyhow.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tempfile.workspace = true
 time = { version = "0.3", features = ["serde"] }
-tempfile = "3.10"
 once_cell = "1.19"
 hostname = "0.3"
 age-core = "0.10"
 peg = "0.8"
-age = { version = "0.10", features = ["ssh", "armor"] }
 base64 = "0.22.1"
 chrono = { version = "0.4", features = ["serde"] }
-# Using fixed version for rust on stable nixos branches.
-clap = { version = ">=4.4, <4.5", features = [
-	"derive",
-	"env",
-	"wrap_help",
-	"unicode",
-] }
-tracing = "0.1"
-tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
 tokio-util = { version = "0.7", features = ["codec"] }
 async-trait = "0.1"
 futures = "0.3"
@@ -40,9 +34,7 @@
 	"supports-color",
 	"supports-colors",
 ] }
-r2d2 = "0.8.10"
 abort-on-drop = "0.2"
-unindent = "0.2"
 regex = "1.10"
 openssh = "0.10"
 crossterm = { version = "0.27.0", features = ["use-dev-tty"] }
@@ -51,12 +43,13 @@
 tracing-indicatif = { version = "0.3", optional = true }
 human-repr = { version = "1.1", optional = true }
 indicatif = { version = "0.17", optional = true }
+nix-eval = { version = "0.1.0", path = "../../crates/nix-eval" }
 
 [features]
 # Not quite stable
 indicatif = [
-	"tracing-indicatif",
+	"dep:tracing-indicatif",
 	"dep:indicatif",
-	"human-repr",
+	"dep:human-repr",
 	"better-command/indicatif",
 ]
modifiedcmds/fleet/src/better_nix_eval.rsdiffbeforeafterboth
--- a/cmds/fleet/src/better_nix_eval.rs
+++ b/cmds/fleet/src/better_nix_eval.rs
@@ -12,892 +12,14 @@
 use better_command::{ClonableHandler, Handler, NixHandler, NoopHandler};
 use futures::StreamExt;
 use itertools::Itertools;
-use r2d2::{Pool, PooledConnection};
 use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
 use tokio::io::AsyncWriteExt;
 use tokio::process::{ChildStderr, ChildStdin, ChildStdout, Command};
 use tokio::select;
 use tokio::sync::{mpsc, oneshot, Mutex};
-use tokio_util::codec::{FramedRead, LinesCodec};
 use tracing::{debug, error, warn, Level};
-
-const REPL_DELIMITER: &str = "\"FLEET_MAGIC_REPL_DELIMITER\"";
-
-pub struct NixSessionInner {
-	full_delimiter: String,
-	nix_handler: ClonableHandler<NixHandler>,
-	out: OutputHandler,
-	stdin: ChildStdin,
-	string_wrapping: (String, String),
-	number_wrapping: (String, String),
-
-	executing_command: Arc<Mutex<()>>,
-
-	next_id: u32,
-	free_list: Vec<u32>,
-}
-const TRAIN_STRING: &str = "\"TRAIN_STRING\"";
-const TRAIN_NUMBER: &str = "13141516";
-
-#[must_use]
-struct ErrorCollector<'i, H> {
-	collected: Vec<String>,
-	inner: &'i mut H,
-}
-impl<'i, H> ErrorCollector<'i, H> {
-	fn new(inner: &'i mut H) -> Self {
-		Self {
-			collected: vec![],
-			inner,
-		}
-	}
-}
-impl<H> ErrorCollector<'_, H> {
-	fn handle_line_inner(&mut self, msg: &str) -> bool {
-		let Some(msg) = msg.strip_prefix("@nix ") else {
-			return false;
-		};
-		#[derive(Deserialize)]
-		struct ErrorAction {
-			action: String,
-			level: u32,
-			msg: String,
-		}
-		let Ok(act) = serde_json::from_str::<ErrorAction>(msg) else {
-			return false;
-		};
-		if act.action != "msg" || act.level != 0 {
-			return false;
-		}
-		self.collected.push(act.msg);
-		true
-	}
-	fn finish(self) -> Result<()> {
-		// fn dedent(s: String) -> String {
-		// 	s.split('\n').filter(|s| !s.trim().is_empty()).map(|v| v.)
-		// }
-		if !self.collected.is_empty() {
-			bail!(
-				"{}",
-				self.collected
-					.iter()
-					.map(|v| {
-						if let Some(f) = v.strip_prefix("\u{1b}[31;1merror:\u{1b}[0m ") {
-							let v = unindent::unindent(f.trim_start());
-							v.trim().to_owned()
-						} else {
-							v.to_owned()
-						}
-					})
-					.join("\n")
-			);
-		}
-		Ok(())
-	}
-	fn flush(self) {
-		for line in self.collected {
-			warn!("{line}");
-		}
-	}
-}
-impl<H: Handler> Handler for ErrorCollector<'_, H> {
-	fn handle_line(&mut self, e: &str) {
-		if self.handle_line_inner(e) {
-			return;
-		}
-		self.inner.handle_line(e)
-	}
-}
-
-enum OutputLine {
-	Out(String),
-	Err(String),
-}
-struct OutputHandler {
-	rx: mpsc::Receiver<OutputLine>,
-	_cancel_handle: oneshot::Receiver<()>,
-}
-impl OutputHandler {
-	fn new(out: ChildStdout, err: ChildStderr) -> Self {
-		let mut out = FramedRead::new(out, LinesCodec::new());
-		let mut err = FramedRead::new(err, LinesCodec::new());
-		let (tx, rx) = mpsc::channel(20);
-		let (mut cancelled, _cancel_handle) = oneshot::channel();
-		tokio::spawn(async move {
-			loop {
-				select! {
-					// We should receive errors earlier than synchronization
-					biased;
-					e = err.next() => {
-						let Some(Ok(e)) = e else {
-							if e.is_some() {
-								error!("bad repl stderr: {e:?}");
-							}
-							continue;
-						};
-						let _ = tx.send(OutputLine::Err(e)).await;
-					}
-					o = out.next() => {
-						let Some(Ok(o)) = o else {
-							if o.is_some() {
-								error!("bad repl stdout: {o:?}");
-							}
-							continue;
-						};
-						let _ = tx.send(OutputLine::Out(o)).await;
-					}
-					// Reader doesn't care about stdout, as this is cancelled.
-					// Error still might be useful, to process leftover span closures?
-					_ = cancelled.closed() => {
-						break;
-					}
-				}
-			}
-		});
-		Self { rx, _cancel_handle }
-	}
-	async fn next(&mut self) -> Option<OutputLine> {
-		self.rx.recv().await
-	}
-}
-
-struct WarnHandler;
-impl Handler for WarnHandler {
-	fn handle_line(&mut self, e: &str) {
-		warn!(target: "nix", "{e}")
-	}
-}
-
-impl NixSessionInner {
-	async fn new(flake: &OsStr, extra_args: impl IntoIterator<Item = &OsStr>) -> Result<Self> {
-		let mut cmd = Command::new("nix");
-		cmd.arg("repl")
-			.arg(flake)
-			.arg("--log-format")
-			.arg("internal-json");
-		for arg in extra_args {
-			cmd.arg(arg);
-		}
-		cmd.stdin(Stdio::piped());
-		cmd.stdout(Stdio::piped());
-		cmd.stderr(Stdio::piped());
-		let cmd = cmd.spawn()?;
-		let stdout = cmd.stdout.unwrap();
-		let stderr = cmd.stderr.unwrap();
-		let mut out = OutputHandler::new(stdout, stderr);
-		let mut stdin = cmd.stdin.unwrap();
-		// Standard repl hello doesn't work with internal-json logger
-		stdin.write_all(REPL_DELIMITER.as_bytes()).await?;
-		stdin.write_all(b"\n").await?;
-		stdin.flush().await?;
-		let nix_handler = NixHandler::default();
-		let mut full_delimiter = None;
-		let mut errors = vec![];
-		while let Some(line) = out.next().await {
-			let line = match line {
-				OutputLine::Out(o) => o,
-				OutputLine::Err(_e) => {
-					// Handle startup errors, but skip repl hello?
-					errors.push(_e);
-					continue;
-				}
-			};
-			if line.contains(REPL_DELIMITER) {
-				debug!("discovered repl delimiter with added colors: {line}");
-				full_delimiter = Some(line.to_owned());
-				break;
-			}
-		}
-		let Some(full_delimiter) = full_delimiter else {
-			for e in errors {
-				error!("{e}");
-			}
-			bail!("failed to discover delimiter");
-		};
-		let mut res = Self {
-			full_delimiter,
-			nix_handler: ClonableHandler::new(nix_handler),
-			out,
-			stdin,
-			string_wrapping: Default::default(),
-			number_wrapping: Default::default(),
-
-			executing_command: Arc::new(Mutex::new(())),
-
-			next_id: 0,
-			free_list: vec![],
-		};
-		res.train().await?;
-		Ok(res)
-	}
-	async fn train(&mut self) -> Result<()> {
-		{
-			let full_string = self
-				.execute_expression_raw(TRAIN_STRING, &mut NoopHandler)
-				.await?;
-			let string_offset = full_string.find(TRAIN_STRING).expect("contained");
-			let string_prefix = &full_string[..string_offset];
-			let string_suffix = &full_string[string_offset + TRAIN_STRING.len()..];
-			self.string_wrapping = (string_prefix.to_owned(), string_suffix.to_owned());
-		}
-		{
-			let full_number = self
-				.execute_expression_raw(TRAIN_NUMBER, &mut NoopHandler)
-				.await?;
-			let number_offset = full_number.find(TRAIN_NUMBER).expect("contained");
-			let number_prefix = &full_number[..number_offset];
-			let number_suffix = &full_number[number_offset + TRAIN_NUMBER.len()..];
-			self.number_wrapping = (number_prefix.to_owned(), number_suffix.to_owned());
-		}
-		Ok(())
-	}
-	async fn send_command(&mut self, cmd: impl AsRef<[u8]>) -> Result<()> {
-		if tracing::enabled!(Level::DEBUG) && cmd.as_ref() != REPL_DELIMITER.as_bytes() {
-			let cmd_str = String::from_utf8_lossy(cmd.as_ref());
-			tracing::debug!("{cmd_str}");
-		};
-		self.stdin.write_all(cmd.as_ref()).await?;
-		self.stdin.write_all(b"\n").await?;
-		Ok(())
-	}
-	async fn read_until_delimiter(&mut self, err_handler: &mut dyn Handler) -> Result<String> {
-		let mut out = String::new();
-		while let Some(line) = self.out.next().await {
-			let line = match line {
-				OutputLine::Out(out) => out,
-				OutputLine::Err(err) => {
-					err_handler.handle_line(&err);
-					continue;
-				}
-			};
-			if line == self.full_delimiter {
-				return Ok(out);
-			}
-			if !out.is_empty() {
-				out.push('\n');
-			}
-			out.push_str(&line);
-		}
-		bail!("didn't reached delimiter");
-	}
-	async fn execute_expression_number(&mut self, expr: impl AsRef<[u8]>) -> Result<u64> {
-		let num = self.number_wrapping.clone();
-		let n = self.execute_expression_wrapping(expr, &num).await?;
-		Ok(n.parse::<u64>()?)
-	}
-	async fn execute_expression_string(&mut self, expr: impl AsRef<[u8]>) -> Result<String> {
-		let num = self.string_wrapping.clone();
-		let n = self.execute_expression_wrapping(expr, &num).await?;
-		let str: String = serde_json::from_str(&n)?;
-		Ok(str)
-	}
-	async fn execute_expression_to_json<V: DeserializeOwned>(
-		&mut self,
-		expr: impl AsRef<[u8]>,
-	) -> Result<V> {
-		let mut fexpr = b"builtins.toJSON (".to_vec();
-		fexpr.extend_from_slice(expr.as_ref());
-		fexpr.push(b')');
-		let v = self
-			.execute_expression_string(fexpr)
-			.await
-			.context("string expression")?;
-		serde_json::from_str(&v).context("json parse")
-	}
-	async fn execute_expression_wrapping(
-		&mut self,
-		expr: impl AsRef<[u8]>,
-		wrapping: &(String, String),
-	) -> Result<String> {
-		let mut nix_handler = self.nix_handler.clone();
-		let mut collected = ErrorCollector::new(&mut nix_handler);
-		let res = self.execute_expression_raw(expr, &mut collected).await?;
-		if res.is_empty() {
-			collected.finish()?;
-			bail!("expected expression, got nothing")
-		} else {
-			collected.flush()
-		};
-		let Some(res) = res.strip_prefix(&wrapping.0) else {
-			bail!("invalid type")
-		};
-		let Some(res) = res.strip_suffix(&wrapping.1) else {
-			bail!("invalid type")
-		};
-		Ok(res.to_owned())
-	}
-	async fn execute_expression_empty(&mut self, expr: impl AsRef<[u8]>) -> Result<()> {
-		let mut nix_handler = self.nix_handler.clone();
-		let mut collected = ErrorCollector::new(&mut nix_handler);
-		let v = self.execute_expression_raw(expr, &mut collected).await?;
-		collected.finish()?;
-		ensure!(v.is_empty(), "unexpected expression result");
-		Ok(())
-	}
-	async fn execute_expression_raw(
-		&mut self,
-		expr: impl AsRef<[u8]>,
-		err_handler: &mut dyn Handler,
-	) -> Result<String> {
-		// Prevent two commands from being executed in parallel, messing with each other.
-		let _lock = self.executing_command.clone();
-		let _guard = _lock.lock().await;
-
-		self.send_command(expr).await?;
-		// It will be echoed
-		self.send_command(REPL_DELIMITER).await?;
-		self.read_until_delimiter(err_handler).await
-	}
-	async fn execute_assign(&mut self, expr: impl AsRef<str>) -> Result<u32> {
-		let id = self.allocate_id();
-		self.execute_expression_empty(format!("sess_field_{id} = {}", expr.as_ref()))
-			.await?;
-		Ok(id)
-	}
-
-	/// Id should be immediately used
-	fn allocate_id(&mut self) -> u32 {
-		if let Some(free) = self.free_list.pop() {
-			free
-		} else {
-			let v = self.next_id;
-			self.next_id += 1;
-			v
-		}
-	}
-	// Nix has no way to deallocate variable, yet GC will correct everything not reachable.
-	// async fn free_id(&mut self, id: u32) -> Result<()> {
-	// 	self.execute_expression_empty(format!("sess_field_{id} = null"))
-	// 		.await?;
-	// 	self.free_list.push(id);
-	// 	Ok(())
-	// }
-}
-
-#[derive(Clone)]
-pub struct NixSession(Arc<tokio::sync::Mutex<PooledConnection<NixSessionPoolInner>>>);
-
-#[derive(Clone)]
-pub struct NixExprBuilder {
-	out: String,
-	used_fields: Vec<Field>,
-}
-impl NixExprBuilder {
-	pub fn object() -> Self {
-		NixExprBuilder {
-			out: "{ ".to_owned(),
-			used_fields: Vec::new(),
-		}
-	}
-	pub fn string(s: &str) -> Self {
-		NixExprBuilder {
-			out: nixlike::serialize(s)
-				.expect("no problems with serializing_string")
-				.trim_end()
-				.to_owned(),
-			used_fields: Vec::new(),
-		}
-	}
-	pub fn serialized(v: impl Serialize) -> Self {
-		let serialized = nixlike::serialize(v).expect("invalid value for apply");
-		Self {
-			out: serialized.trim_end().to_owned(),
-			used_fields: Vec::new(),
-		}
-	}
-	pub fn field(f: Field) -> Self {
-		Self {
-			out: format!("sess_field_{}", f.0.value.expect("no value")),
-			used_fields: vec![f],
-		}
-	}
-	pub fn end_obj(&mut self) {
-		self.out.push('}');
-	}
-	pub fn obj_key(&mut self, name: Self, value: Self) {
-		self.out.push_str(r#""${"#);
-		self.extend(name);
-		self.out.push_str(r#"}" = "#);
-		self.extend(value);
-		self.out.push_str("; ");
-	}
-
-	pub fn extend(&mut self, e: Self) {
-		self.out.push_str(&e.out);
-		self.used_fields.extend(e.used_fields);
-	}
-
-	#[allow(dead_code)]
-	pub fn session(&self) -> NixSession {
-		let mut session = None;
-		for ele in &self.used_fields {
-			if session.is_none() {
-				session = Some(ele.0.session.clone());
-				continue;
-			}
-			let session = &session.as_ref().expect("checked").0;
-			let ele_sess = &ele.0.session.0;
-			assert!(
-				Arc::ptr_eq(session, ele_sess),
-				"can't mix fields from different session"
-			);
-		}
-		session.expect("expr without fields used")
-	}
-	#[allow(dead_code)]
-	pub fn index_attr(&mut self, s: &str) {
-		let escaped = nixlike::serialize(s).expect("string");
-		self.out.push('.');
-		self.out.push_str(escaped.trim_end());
-	}
-}
-
-#[macro_export]
-macro_rules! nix_expr_inner {
-	//(@munch_object FIXME: value should be arbitrary nix_expr_inner input... Time to write proc-macro?
-	(@obj($o:ident) $field:ident, $($tt:tt)*) => {{
-		$o.obj_key(
-			NixExprBuilder::string(stringify!($field)),
-			NixExprBuilder::field($field),
-		);
-		nix_expr_inner!(@obj($o) $($tt)*);
-	}};
-	(@obj($o:ident) $field:ident: $v:block, $($tt:tt)*) => {{
-		$o.obj_key(
-			NixExprBuilder::string(stringify!($field)),
-			NixExprBuilder::serialized(&$v),
-		);
-		nix_expr_inner!(@obj($o) $($tt)*);
-	}};
-	(@obj($o:ident)) => {{}};
-	(Obj { $($tt:tt)* }) => {{
-		use $crate::{better_nix_eval::NixExprBuilder, nix_expr_inner};
-		let mut out = NixExprBuilder::object();
-		nix_expr_inner!(@obj(out) $($tt)*);
-		out.end_obj();
-		out
-	}};
-	(@field($o:ident) . $var:ident $($tt:tt)*) => {{
-		$o.index_attr(stringify!($var));
-		nix_expr_inner!(@field($o) $($tt)*);
-	}};
-	(@field($o:ident) [{ $v:expr }] $($tt:tt)*) => {{
-		$o.push(Index::attr(&$v));
-		nix_expr_inner!(@o($o) $($tt)*);
-	}};
-	(@field($o:ident) [ $($var:tt)+ ] $($tt:tt)*) => {{
-		$o.push(Index::Expr($crate::nix_expr_inner!($($var)+)));
-		nix_expr_inner!(@o($o) $($tt)*);
-	}};
-	(@field($o:ident) ($($var:tt)*) $($tt:tt)*) => {
-		$o.push(Index::ExprApply($crate::nix_expr_inner!($($var)+)));
-		nix_expr_inner!(@o($o) $($tt)*);
-	};
-	(@field($o:ident)) => {};
-	($field:ident $($tt:tt)*) => {{
-		use $crate::{better_nix_eval::NixExprBuilder, nix_expr_inner};
-		#[allow(unused_mut, reason = "might be used if indexed")]
-		let mut out = NixExprBuilder::field($field.clone());
-		nix_expr_inner!(@field(out) $($tt)*);
-		out
-	}};
-	($v:literal) => {{
-		use $crate::better_nix_eval::NixExprBuilder;
-		NixExprBuilder::string($v)
-	}};
-	({$v:expr}) => {{
-		use $crate::better_nix_eval::NixExprBuilder;
-		NixExprBuilder::serialized(&$v)
-	}}
-}
-#[macro_export]
-macro_rules! nix_expr {
-	($($tt:tt)+) => {{
-		use $crate::{better_nix_eval::{NixExprBuilder, Field}, nix_expr_inner};
-		let expr = nix_expr_inner!($($tt)+);
-		Field::new(expr.session(), expr.out)
-	}};
-}
 
-#[macro_export]
-macro_rules! nix_go {
-	(@o($o:ident) . $var:ident $($tt:tt)*) => {{
-		$o.push(Index::attr(stringify!($var)));
-		nix_go!(@o($o) $($tt)*);
-	}};
-	(@o($o:ident) [{ $v:expr }] $($tt:tt)*) => {{
-		$o.push(Index::attr(&$v));
-		nix_go!(@o($o) $($tt)*);
-	}};
-	(@o($o:ident) [ $($var:tt)+ ] $($tt:tt)*) => {{
-		$o.push(Index::Expr($crate::nix_expr_inner!($($var)+)));
-		nix_go!(@o($o) $($tt)*);
-	}};
-	(@o($o:ident) ($($var:tt)*) $($tt:tt)*) => {
-		$o.push(Index::ExprApply($crate::nix_expr_inner!($($var)+)));
-		nix_go!(@o($o) $($tt)*);
-	};
-	(@o($o:ident) | $($var:tt)*) => {
-		$o.push(Index::Pipe($crate::nix_expr_inner!($($var)+)));
-	};
-	(@o($o:ident)) => {};
-	($field:ident $($tt:tt)+) => {{
-		use $crate::{nix_go, better_nix_eval::Index};
-		let field = $field.clone();
-		let mut out = vec![];
-		nix_go!(@o(out) $($tt)*);
-		field.select(out).await?
-	}}
-}
-#[macro_export]
-macro_rules! nix_go_json {
-	($($tt:tt)*) => {{
-		$crate::nix_go!($($tt)*).as_json().await?
-	}};
-}
 
-#[derive(Clone)]
-pub enum Index {
-	Var(String),
-	String(String),
-	#[allow(dead_code)]
-	Apply(String),
-	#[allow(dead_code)]
-	Expr(NixExprBuilder),
-	ExprApply(NixExprBuilder),
-	Pipe(NixExprBuilder),
-}
-impl Index {
-	pub fn var(v: impl AsRef<str>) -> Self {
-		let v = v.as_ref();
-		assert!(
-			!(v.contains('.') | v.contains(' ')),
-			"bad variable name: {v}"
-		);
-		Self::Var(v.to_owned())
-	}
-	pub fn attr(v: impl AsRef<str>) -> Self {
-		Self::String(v.as_ref().to_owned())
-	}
-	#[allow(dead_code)]
-	pub fn apply(v: impl Serialize) -> Self {
-		let serialized = nixlike::serialize(v).expect("invalid value for apply");
-		Self::Apply(serialized.trim_end().to_owned())
-	}
-}
-impl Display for Index {
-	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-		match self {
-			Index::Var(v) => {
-				write!(f, "{v}")
-			}
-			Index::String(k) => {
-				let v = nixlike::format_identifier(k.as_str());
-				write!(f, ".{v}")
-			}
-			Index::Apply(o) => {
-				write!(f, "<apply>({o})")
-			}
-			Index::Expr(e) => {
-				write!(f, "[{}]", e.out)
-			}
-			Index::ExprApply(e) => {
-				write!(f, "<apply>({})", e.out)
-			}
-			Index::Pipe(e) => {
-				write!(f, "<map>({})", e.out)
-			}
-		}
-	}
-}
-impl fmt::Debug for Index {
-	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-		write!(f, "{self}")
-	}
-}
-struct PathDisplay<'i>(&'i [Index]);
-impl Display for PathDisplay<'_> {
-	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-		for i in self.0 {
-			write!(f, "{i}")?;
-		}
-		Ok(())
-	}
-}
-struct FieldInner {
-	full_path: Option<Vec<Index>>,
-	session: NixSession,
-	value: Option<u32>,
-}
-fn context(op: &str, full_path: Option<&[Index]>, query: &str) -> String {
-	if let Some(full_path) = &full_path {
-		format!("on {op}, full path: {}", PathDisplay(full_path))
-	} else {
-		format!("query: {query:?}")
-	}
-}
-#[derive(Clone)]
-pub struct Field(Arc<FieldInner>);
-impl Field {
-	fn root(session: NixSession) -> Self {
-		Self(Arc::new(FieldInner {
-			full_path: Some(vec![]),
-			session,
-			value: None,
-		}))
-	}
-	async fn new(session: NixSession, query: &str) -> Result<Self> {
-		let vid = session
-			.0
-			.lock()
-			.await
-			.execute_assign(query)
-			.await
-			.with_context(|| context("new root", None, query))?;
-		Ok(Self(Arc::new(FieldInner {
-			full_path: None,
-			session,
-			value: Some(vid),
-		})))
-	}
-	pub async fn field(session: NixSession, field: &str) -> Result<Self> {
-		Self::root(session).select([Index::var(field)]).await
-	}
-	pub async fn select<'a>(&self, name: impl IntoIterator<Item = Index>) -> Result<Self> {
-		let mut used_fields = Vec::new();
-		let mut name = name.into_iter();
 
-		let mut full_path = self.0.full_path.clone();
-		let mut query = if let Some(id) = self.0.value {
-			format!("sess_field_{id}")
-		} else {
-			let first = name.next();
-			if let Some(Index::Var(i)) = first {
-				if let Some(full_path) = &mut full_path {
-					full_path.push(Index::Var(i.clone()));
-				}
-				i.clone()
-			} else {
-				panic!("first path item should be variable, got {first:?}")
-			}
-		};
-		for v in name {
-			if let Some(full_path) = &mut full_path {
-				full_path.push(v.clone());
-			}
-			match v {
-				Index::Var(_) => panic!("var item may only be first"),
-				Index::String(s) => {
-					let escaped = nixlike::serialize(s)?;
-					query.push('.');
-					query.push_str(escaped.trim());
-				}
-				Index::Apply(a) => {
-					// In cases like `a {}.b` first `{}.b` will be evaluated, so `a {}` should be encased in `()`
-					query = format!("({query} {a})");
-				}
-				Index::Expr(e) => {
-					let index = Field::new(self.0.session.clone(), &e.out).await?;
-					used_fields.push(index.clone());
-					query.push('.');
-					let index = format!("${{sess_field_{}}}", index.0.value.expect("value"));
-					query.push_str(&index);
-				}
-				Index::ExprApply(e) => {
-					let index = Field::new(self.0.session.clone(), &e.out).await?;
-					used_fields.push(index.clone());
-					query.push(' ');
-					let index = format!("sess_field_{}", index.0.value.expect("value"));
-					query.push_str(&index);
-					query = format!("({query})");
-				}
-				Index::Pipe(v) => {
-					let index = Field::new(self.0.session.clone(), &v.out).await?;
-					used_fields.push(index.clone());
-					let index = format!("sess_field_{}", index.0.value.expect("value"));
-					query = format!("({index} {query})");
-				}
-			}
-		}
 
-		let vid = self
-			.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_assign(&query)
-			.await
-			.with_context(|| {
-				if let Some(full_path) = &full_path {
-					format!("full path: {}", PathDisplay(full_path))
-				} else {
-					format!("query: {query:?}")
-				}
-			})?;
-		Ok(Self(Arc::new(FieldInner {
-			full_path,
-			session: self.0.session.clone(),
-			value: Some(vid),
-		})))
-	}
-	pub async fn as_json<V: DeserializeOwned>(&self) -> Result<V> {
-		let id = self.0.value.expect("can't serialize root field");
-		let query = format!("sess_field_{id}");
-		self.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_expression_to_json(&query)
-			.await
-			.with_context(|| context("as_json", self.0.full_path.as_deref(), &query))
-	}
-	#[allow(dead_code)]
-	pub async fn has_field(&self, name: &str) -> Result<bool> {
-		let id = self.0.value.expect("can't list root fields");
-		let key = nixlike::escape_string(name);
-		let query = format!("sess_field_{id} ? {key}");
-		self.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_expression_to_json(&query)
-			.await
-			.with_context(|| context("has_field", self.0.full_path.as_deref(), &query))
-	}
-	pub async fn list_fields(&self) -> Result<Vec<String>> {
-		let id = self.0.value.expect("can't list root fields");
-		let query = format!("builtins.attrNames sess_field_{id}");
-		self.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_expression_to_json(&query)
-			.await
-			.with_context(|| context("list field", self.0.full_path.as_deref(), &query))
-	}
-	pub async fn type_of(&self) -> Result<String> {
-		let id = self.0.value.expect("can't list root fields");
-		let query = format!("builtins.typeOf sess_field_{id}");
-		self.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_expression_to_json(&query)
-			.await
-			.with_context(|| context("type_of", self.0.full_path.as_deref(), &query))
-	}
-	#[allow(dead_code)]
-	pub async fn import(&self) -> Result<Self> {
-		let import = Self::new(self.0.session.clone(), "import").await?;
-		Ok(nix_go!(self | import))
-	}
-	pub async fn build(&self) -> Result<HashMap<String, PathBuf>> {
-		let id = self.0.value.expect("can't use build on not-value");
-		let query = format!(":b sess_field_{id}");
-		let vid = self
-			.0
-			.session
-			.0
-			.lock()
-			.await
-			.execute_expression_raw(&query, &mut NixHandler::default())
-			.await?;
-		ensure!(
-			!vid.is_empty(),
-			"build failed: {}",
-			context("build", self.0.full_path.as_deref(), &query),
-		);
-		let Some(vid) = vid.strip_prefix("This derivation produced the following outputs:\n")
-		else {
-			panic!("unexpected build output: {vid:?}");
-		};
-		let outputs = vid
-			.split('\n')
-			.filter(|v| !v.is_empty())
-			.map(|v| v.split_once(" -> ").expect("unexpected build output"))
-			.map(|(a, b)| (a.trim_start().to_owned(), PathBuf::from(b)))
-			.collect();
-		Ok(outputs)
-	}
-}
-impl Drop for FieldInner {
-	fn drop(&mut self) {
-		if let Some(id) = self.value {
-			if let Ok(mut lock) = self.session.0.try_lock() {
-				lock.free_list.push(id)
-			}
-			// Leaked
-		}
-	}
-}
-struct NixSessionPoolInner {
-	flake: OsString,
-	nix_args: Vec<OsString>,
-}
-
-#[derive(Debug)]
-pub struct NixPoolError(anyhow::Error);
-impl From<anyhow::Error> for NixPoolError {
-	fn from(value: anyhow::Error) -> Self {
-		Self(value)
-	}
-}
-impl std::error::Error for NixPoolError {}
-impl std::fmt::Display for NixPoolError {
-	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-		self.0.fmt(f)
-	}
-}
-impl r2d2::ManageConnection for NixSessionPoolInner {
-	type Connection = NixSessionInner;
-	type Error = NixPoolError;
-	fn connect(&self) -> std::result::Result<Self::Connection, Self::Error> {
-		let _v = TOKIO_RUNTIME
-			.get()
-			.expect("missed tokio runtime init!")
-			.enter();
-		Ok(futures::executor::block_on(NixSessionInner::new(
-			self.flake.as_os_str(),
-			self.nix_args.iter().map(OsString::as_os_str),
-		))?)
-	}
-
-	fn is_valid(&self, conn: &mut Self::Connection) -> std::result::Result<(), Self::Error> {
-		let _v = TOKIO_RUNTIME
-			.get()
-			.expect("missed tokio runtime init!")
-			.enter();
-		let res = futures::executor::block_on(conn.execute_expression_number("2 + 2"))?;
-		if res != 4 {
-			return Err(anyhow!("sanity check failed").into());
-		};
-		Ok(())
-	}
-
-	fn has_broken(&self, _conn: &mut Self::Connection) -> bool {
-		false
-	}
-}
-pub struct NixSessionPool(Pool<NixSessionPoolInner>);
-impl NixSessionPool {
-	pub async fn new(flake: OsString, nix_args: Vec<OsString>) -> Result<Self> {
-		let inner = tokio::task::block_in_place(|| {
-			r2d2::Builder::<NixSessionPoolInner>::new()
-				.min_idle(Some(0))
-				.build(NixSessionPoolInner { flake, nix_args })
-		})?;
-		Ok(Self(inner))
-	}
-	pub async fn get(&self) -> Result<NixSession> {
-		let v = tokio::task::block_in_place(|| self.0.get())?;
-		Ok(NixSession(Arc::new(tokio::sync::Mutex::new(v))))
-	}
-}
-
-pub static TOKIO_RUNTIME: OnceLock<tokio::runtime::Handle> = OnceLock::new();
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -4,10 +4,10 @@
 
 use crate::command::MyCommand;
 use crate::host::{Config, ConfigHost};
-use crate::nix_go;
 use anyhow::{anyhow, Result};
 use clap::{Parser, ValueEnum};
 use itertools::Itertools as _;
+use nix_eval::nix_go;
 use tokio::{task::LocalSet, time::sleep};
 use tracing::{error, field, info, info_span, warn, Instrument};
 
modifiedcmds/fleet/src/cmds/info.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/info.rs
+++ b/cmds/fleet/src/cmds/info.rs
@@ -1,9 +1,9 @@
 use std::collections::BTreeSet;
 
 use crate::host::Config;
-use crate::nix_go_json;
 use anyhow::{ensure, Result};
 use clap::Parser;
+use nix_eval::nix_go_json;
 
 #[derive(Parser)]
 pub struct Info {
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use owo_colors::OwoColorize;15use serde::Deserialize;16use tabled::{Table, Tabled};17use tokio::{fs::read, process::Command};18use tracing::{error, info, info_span, warn, Instrument};1920use crate::{21	better_nix_eval::Field,22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24	nix_go, nix_go_json,25};2627#[derive(Parser)]28pub enum Secret {29	/// Force load host keys for all defined hosts30	ForceKeys,31	/// Add secret, data should be provided in stdin32	AddShared {33		/// Secret name34		name: String,35		/// Secret owners36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// How to name public secret part44		#[clap(long, default_value = "public")]45		public_name: String,46		/// Load public part from specified file47		#[clap(long)]48		public_file: Option<PathBuf>,4950		/// Create a notification on secret expiration51		#[clap(long)]52		expires_at: Option<DateTime<Utc>>,5354		/// Secret with this name already exists, override its value while keeping the same owners.55		#[clap(long)]56		re_add: bool,5758		#[clap(default_value = "secret")]59		part_name: String,60	},61	/// Add secret, data should be provided in stdin62	Add {63		/// Secret name64		name: String,65		/// Secret owners66		machine: String,67		/// Override secret if already present68		#[clap(long)]69		force: bool,70		/// Secret public part71		#[clap(long)]72		public: Option<String>,73		/// How to name public secret part74		#[clap(long, default_value = "public")]75		public_name: String,76		/// Load public part from specified file77		#[clap(long)]78		public_file: Option<PathBuf>,7980		#[clap(default_value = "secret")]81		part_name: String,82	},83	/// Read secret from remote host, requires sudo on said host84	Read {85		name: String,86		machine: String,8788		#[clap(default_value = "secret")]89		part_name: String,90	},91	UpdateShared {92		name: String,9394		#[clap(long)]95		machines: Option<Vec<String>>,9697		#[clap(long)]98		add_machines: Vec<String>,99		#[clap(long)]100		remove_machines: Vec<String>,101102		/// Which host should we use to decrypt103		#[clap(long)]104		prefer_identities: Vec<String>,105106		#[clap(default_value = "secret")]107		part_name: String,108	},109	Regenerate {110		/// Which host should we use to decrypt, in case if reencryption is required, without111		/// regeneration112		#[clap(long)]113		prefer_identities: Vec<String>,114	},115	List {},116	Edit {117		name: String,118		machine: String,119120		#[clap(default_value = "secret")]121		part: String,122123		#[clap(long)]124		add: bool,125	},126}127128#[tracing::instrument(skip(config, secret, field, prefer_identities))]129async fn update_owner_set(130	secret_name: &str,131	config: &Config,132	mut secret: FleetSharedSecret,133	field: Field,134	updated_set: &[String],135	prefer_identities: &[String],136) -> Result<FleetSharedSecret> {137	let original_set = secret.owners.clone();138139	let set = original_set.iter().collect::<BTreeSet<_>>();140	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();141142	if set == expected_set {143		info!("no need to update owner list, it is already correct");144		return Ok(secret);145	}146147	let should_regenerate = if set.difference(&expected_set).next().is_some() {148		// TODO: Remove this warning for revokable secrets.149		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");150		nix_go_json!(field.regenerateOnOwnerRemoved)151	} else if expected_set.difference(&set).next().is_some() {152		nix_go_json!(field.regenerateOnOwnerAdded)153	} else {154		false155	};156157	if should_regenerate {158		info!("secret is owner-dependent, will regenerate");159		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;160		Ok(generated)161	} else {162		let identity_holder = if !prefer_identities.is_empty() {163			prefer_identities164				.iter()165				.find(|i| original_set.iter().any(|s| s == *i))166		} else {167			secret.owners.first()168		};169		let Some(identity_holder) = identity_holder else {170			bail!("no available holder found");171		};172173		for (part_name, part) in secret.secret.parts.iter_mut() {174			let _span = info_span!("part reencryption", part_name);175			if !part.raw.encrypted {176				continue;177			}178			let host = config.host(identity_holder).await?;179			let encrypted = host180				.reencrypt(part.raw.clone(), updated_set.to_vec())181				.await?;182			part.raw = encrypted;183		}184185		secret.owners = updated_set.to_vec();186		Ok(secret)187	}188}189190#[derive(Deserialize)]191#[serde(rename_all = "camelCase")]192enum GeneratorKind {193	Impure,194	Pure,195}196197async fn generate_pure(198	_config: &Config,199	_display_name: &str,200	_secret: Field,201	_default_generator: Field,202	_owners: &[String],203) -> Result<FleetSecret> {204	bail!("pure generators are broken for now")205}206async fn generate_impure(207	config: &Config,208	_display_name: &str,209	secret: Field,210	default_generator: Field,211	owners: &[String],212) -> Result<FleetSecret> {213	let generator = nix_go!(secret.generator);214	let on: Option<String> = nix_go_json!(default_generator.impureOn);215216	let host = if let Some(on) = &on {217		config.host(on).await?218	} else {219		config.local_host()220	};221	let on_pkgs = host.pkgs().await?;222	let call_package = nix_go!(on_pkgs.callPackage);223	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);224225	let mut recipients = Vec::new();226	for owner in owners {227		let key = config.key(owner).await?;228		recipients.push(key);229	}230	let encrypt = nix_go!(mk_encrypt_secret(Obj {231		recipients: { recipients },232	}));233234	let generator = nix_go!(call_package(generator)(Obj {235		encrypt,236		// rustfmt_please_newline237	}));238239	let generator = generator.build().await?;240	let generator = generator241		.get("out")242		.ok_or_else(|| anyhow!("missing generateImpure out"))?;243	let generator = host.remote_derivation(generator).await?;244245	let out_parent = host.mktemp_dir().await?;246	let out = format!("{out_parent}/out");247248	let mut gen = host.cmd(generator).await?;249	gen.env("out", &out);250	if on.is_none() {251		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.252		let project_path: String = config253			.directory254			.clone()255			.into_os_string()256			.into_string()257			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;258		gen.env("FLEET_PROJECT", project_path);259	}260	gen.run().await.context("impure generator")?;261262	{263		let marker = host.read_file_text(format!("{out}/marker")).await?;264		ensure!(marker == "SUCCESS", "generation not succeeded");265	}266267	let mut parts = BTreeMap::new();268	for part in host.read_dir(&out).await? {269		if part == "created_at" || part == "expired_at" || part == "marker" {270			continue;271		}272		let contents: SecretData = host273			.read_file_text(format!("{out}/{part}"))274			.await?275			.parse()276			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;277		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });278	}279280	let created_at = host.read_file_value(format!("{out}/created_at")).await?;281	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();282283	Ok(FleetSecret {284		created_at,285		expires_at,286		parts,287	})288}289async fn generate(290	config: &Config,291	display_name: &str,292	secret: Field,293	owners: &[String],294) -> Result<FleetSecret> {295	let generator = nix_go!(secret.generator);296	// Can't properly check on nix module system level297	{298		let gen_ty = generator.type_of().await?;299		if gen_ty == "null" {300			bail!("secret has no generator defined, can't automatically generate it.");301		}302		if gen_ty != "lambda" {303			bail!("generator should be lambda, got {gen_ty}");304		}305	}306	let default_pkgs = &config.default_pkgs;307	let default_call_package = nix_go!(default_pkgs.callPackage);308	// Generators provide additional information in passthru, to access309	// passthru we should call generator, but information about where this generator is supposed to build310	// is located in passthru... Thus evaluating generator on host.311	//312	// Maybe it is also possible to do some magic with __functor?313	//314	// I don't want to make modules always responsible for additional secret data anyway,315	// so it should be in derivation, and not in the secret data itself.316	let default_generator = nix_go!(default_call_package(generator)(Obj {317		encrypt: { "exit 1" },318		// rustfmt_please_newline319	}));320321	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);322323	match kind {324		GeneratorKind::Impure => {325			generate_impure(config, display_name, secret, default_generator, owners).await326		}327		GeneratorKind::Pure => {328			generate_pure(config, display_name, secret, default_generator, owners).await329		}330	}331}332async fn generate_shared(333	config: &Config,334	display_name: &str,335	secret: Field,336	expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);339	Ok(FleetSharedSecret {340		secret: generate(config, display_name, secret, &expected_owners).await?,341		owners: expected_owners,342	})343}344345async fn parse_public(346	public: Option<String>,347	public_file: Option<PathBuf>,348) -> Result<Option<SecretData>> {349	Ok(match (public, public_file) {350		(Some(v), None) => Some(SecretData {351			data: v.into(),352			encrypted: false,353		}),354		(None, Some(v)) => Some(SecretData {355			data: read(v).await?,356			encrypted: false,357		}),358		(Some(_), Some(_)) => {359			bail!("only public or public_file should be set")360		}361		(None, None) => None,362	})363}364365async fn parse_secret() -> Result<Option<Vec<u8>>> {366	let mut input = vec![];367	io::stdin().read_to_end(&mut input)?;368	if input.is_empty() {369		Ok(None)370	} else {371		Ok(Some(input))372	}373}374375fn parse_machines(376	initial: Vec<String>,377	machines: Option<Vec<String>>,378	mut add_machines: Vec<String>,379	mut remove_machines: Vec<String>,380) -> Result<Vec<String>> {381	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {382		bail!("no operation");383	}384385	let initial_machines = initial.clone();386	let mut target_machines = initial;387	info!("Currently encrypted for {initial_machines:?}");388389	// ensure!(machines.is_some() || !add_machines.is_empty() || )390	if let Some(machines) = machines {391		ensure!(392			add_machines.is_empty() && remove_machines.is_empty(),393			"can't combine --machines and --add-machines/--remove-machines"394		);395		let target = initial_machines.iter().collect::<HashSet<_>>();396		let source = machines.iter().collect::<HashSet<_>>();397		for removed in target.difference(&source) {398			remove_machines.push((*removed).clone());399		}400		for added in source.difference(&target) {401			add_machines.push((*added).clone());402		}403	}404405	for machine in &remove_machines {406		let mut removed = false;407		while let Some(pos) = target_machines.iter().position(|m| m == machine) {408			target_machines.swap_remove(pos);409			removed = true;410		}411		if !removed {412			warn!("secret is not enabled for {machine}");413		}414	}415	for machine in &add_machines {416		if target_machines.iter().any(|m| m == machine) {417			warn!("secret is already added to {machine}");418		} else {419			target_machines.push(machine.to_owned());420		}421	}422	if !remove_machines.is_empty() {423		// TODO: maybe force secret regeneration?424		// Not that useful without revokation.425		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");426	}427	Ok(target_machines)428}429impl Secret {430	pub async fn run(self, config: &Config) -> Result<()> {431		match self {432			Secret::ForceKeys => {433				for host in config.list_hosts().await? {434					if config.should_skip(&host.name) {435						continue;436					}437					config.key(&host.name).await?;438				}439			}440			Secret::AddShared {441				mut machines,442				name,443				force,444				public,445				public_name,446				public_file,447				expires_at,448				re_add,449				part_name,450			} => {451				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).452453				let exists = config.has_shared(&name);454				if exists && !force && !re_add {455					bail!("secret already defined");456				}457				if re_add {458					// Fixme: use clap to limit this usage459					ensure!(!force, "--force and --readd are not compatible");460					ensure!(exists, "secret doesn't exists");461					ensure!(462						machines.is_empty(),463						"you can't use machines argument for --readd"464					);465					let shared = config.shared_secret(&name)?;466					machines = shared.owners;467				}468469				let recipients = config.recipients(machines.clone()).await?;470471				let mut parts = BTreeMap::new();472473				let mut input = vec![];474				io::stdin().read_to_end(&mut input)?;475476				if !input.is_empty() {477					let encrypted = encrypt_secret_data(recipients, input)478						.ok_or_else(|| anyhow!("no recipients provided"))?;479					parts.insert(part_name, FleetSecretPart { raw: encrypted });480				}481482				if let Some(public) = parse_public(public, public_file).await? {483					parts.insert(public_name, FleetSecretPart { raw: public });484				}485486				config.replace_shared(487					name,488					FleetSharedSecret {489						owners: machines,490						secret: FleetSecret {491							created_at: Utc::now(),492							expires_at,493							parts,494						},495					},496				);497			}498			Secret::Add {499				machine,500				name,501				force,502				public,503				public_name,504				public_file,505				part_name,506			} => {507				if config.has_secret(&machine, &name) && !force {508					bail!("secret already defined");509				}510511				let mut parts = BTreeMap::new();512513				if let Some(secret) = parse_secret().await? {514					let recipient = config.recipient(&machine).await?;515					let encrypted =516						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");517					parts.insert(part_name, FleetSecretPart { raw: encrypted });518				}519520				if let Some(public) = parse_public(public, public_file).await? {521					parts.insert(public_name, FleetSecretPart { raw: public });522				};523524				config.insert_secret(525					&machine,526					name,527					FleetSecret {528						created_at: Utc::now(),529						expires_at: None,530						parts,531					},532				);533			}534			#[allow(clippy::await_holding_refcell_ref)]535			Secret::Read {536				name,537				machine,538				part_name,539			} => {540				let secret = config.host_secret(&machine, &name)?;541				let Some(secret) = secret.parts.get(&part_name) else {542					bail!("no part {part_name} in secret {name}");543				};544				let data = if secret.raw.encrypted {545					let host = config.host(&machine).await?;546					host.decrypt(secret.raw.clone()).await?547				} else {548					secret.raw.data.clone()549				};550551				stdout().write_all(&data)?;552			}553			Secret::UpdateShared {554				name,555				machines,556				add_machines,557				remove_machines,558				prefer_identities,559				part_name,560			} => {561				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).562563				let secret = config.shared_secret(&name)?;564				if secret.secret.parts.get(&part_name).is_none() {565					bail!("no secret");566				}567568				let initial_machines = secret.owners.clone();569				let target_machines = parse_machines(570					initial_machines.clone(),571					machines,572					add_machines,573					remove_machines,574				)?;575576				if target_machines.is_empty() {577					info!("no machines left for secret, removing it");578					config.remove_shared(&name);579					return Ok(());580				}581582				let config_field = &config.config_unchecked_field;583				let field = nix_go!(config_field.sharedSecrets[{ name }]);584585				let updated = update_owner_set(586					&name,587					config,588					secret,589					field,590					&target_machines,591					&prefer_identities,592				)593				.await?;594				config.replace_shared(name, updated);595			}596			Secret::Regenerate { prefer_identities } => {597				info!("checking for secrets to regenerate");598				{599					let _span = info_span!("shared").entered();600					let expected_shared_set = config601						.list_configured_shared()602						.await?603						.into_iter()604						.collect::<HashSet<_>>();605					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();606					for missing in expected_shared_set.difference(&shared_set) {607						let config_field = &config.config_unchecked_field;608						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);609						let expected_owners: Option<Vec<String>> =610							nix_go_json!(secret.expectedOwners);611						let Some(expected_owners) = expected_owners else {612							// TODO: Might still need to regenerate613							continue;614						};615						info!("generating secret: {missing}");616						let shared = generate_shared(config, missing, secret, expected_owners)617							.in_current_span()618							.await?;619						config.replace_shared(missing.to_string(), shared)620					}621				}622				for host in config.list_hosts().await? {623					if config.should_skip(&host.name) {624						continue;625					}626627					let _span = info_span!("host", host = host.name).entered();628					let expected_set = host629						.list_configured_secrets()630						.in_current_span()631						.await?632						.into_iter()633						.collect::<HashSet<_>>();634					let stored_set = config635						.list_secrets(&host.name)636						.into_iter()637						.collect::<HashSet<_>>();638					for missing in expected_set.difference(&stored_set) {639						info!("generating secret: {missing}");640						let secret = host.secret_field(missing).in_current_span().await?;641						let generated =642							match generate(config, missing, secret, &[host.name.clone()])643								.in_current_span()644								.await645							{646								Ok(v) => v,647								Err(e) => {648									error!("{e:?}");649									continue;650								}651							};652						config.insert_secret(&host.name, missing.to_string(), generated)653					}654				}655				let mut to_remove = Vec::new();656				for name in &config.list_shared() {657					info!("updating secret: {name}");658					let data = config.shared_secret(name)?;659					let config_field = &config.config_unchecked_field;660					let expected_owners: Vec<String> =661						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);662					if expected_owners.is_empty() {663						warn!("secret was removed from fleet config: {name}, removing from data");664						to_remove.push(name.to_string());665						continue;666					}667668					let secret = nix_go!(config_field.sharedSecrets[{ name }]);669					config.replace_shared(670						name.to_owned(),671						update_owner_set(672							name,673							config,674							data,675							secret,676							&expected_owners,677							&prefer_identities,678						)679						.await?,680					);681				}682				for k in to_remove {683					config.remove_shared(&k);684				}685			}686			Secret::List {} => {687				let _span = info_span!("loading secrets").entered();688				let configured = config.list_configured_shared().await?;689				#[derive(Tabled)]690				struct SecretDisplay {691					#[tabled(rename = "Name")]692					name: String,693					#[tabled(rename = "Owners")]694					owners: String,695				}696				let mut table = vec![];697				for name in configured.iter().cloned() {698					let config = config.clone();699					let expected_owners = config.shared_secret_expected_owners(&name).await?;700					let data = config.shared_secret(&name)?;701					let owners = data702						.owners703						.iter()704						.map(|o| {705							if expected_owners.contains(o) {706								o.green().to_string()707							} else {708								o.red().to_string()709							}710						})711						.collect::<Vec<_>>();712					table.push(SecretDisplay {713						owners: owners.join(", "),714						name,715					})716				}717				info!("loaded\n{}", Table::new(table).to_string())718			}719			Secret::Edit {720				name,721				machine,722				part,723				add,724			} => {725				let secret = config.host_secret(&machine, &name)?;726				if let Some(data) = secret.parts.get(&part) {727					let host = config.host(&machine).await?;728					let secret = host.decrypt(data.raw.clone()).await?;729					String::from_utf8(secret).context("secret is not utf8")?730				} else if add {731					String::new()732				} else {733					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");734				};735			}736		}737		Ok(())738	}739}740741async fn edit_temp_file(742	builder: tempfile::Builder<'_, '_>,743	r: Vec<u8>,744	header: &str,745	comment: &str,746) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {747	if !stdin().is_tty() {748		// TODO: Also try to open /dev/tty directly?749		bail!("stdin is not tty, can't open editor");750	}751752	use std::fmt::Write;753	let mut file = builder.tempfile()?;754755	let mut full_header = String::new();756	let mut had = false;757	for line in header.trim_end().lines() {758		had = true;759		writeln!(&mut full_header, "{comment}{line}")?;760	}761	if had {762		writeln!(&mut full_header, "{}", comment.trim_end())?;763	}764	writeln!(765		&mut full_header,766		"{comment}Do not touch this header! It will be removed automatically"767	)?;768769	file.write_all(full_header.as_bytes())?;770	file.write_all(&r)?;771772	let abs_path = file.into_temp_path();773	let editor = std::env::var_os("VISUAL")774		.or_else(|| std::env::var_os("EDITOR"))775		.unwrap_or_else(|| "vi".into());776	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())777		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;778	let editor_args = editor_args779		.into_iter()780		.map(|v| {781			// Only ASCII subsequences are replaced782			unsafe { OsString::from_encoded_bytes_unchecked(v) }783		})784		.collect_vec();785	let Some((editor, args)) = editor_args.split_first() else {786		bail!("EDITOR env var has no command");787	};788	let mut command = Command::new(editor);789	command.args(args);790791	let path_arg = abs_path.canonicalize()?;792793	// TODO: Save full state, using tcget/_getmode/_setmode794	let was_raw = terminal::is_raw_mode_enabled()?;795	terminal::enable_raw_mode()?;796797	let status = command.arg(path_arg).status().await;798799	if !was_raw {800		terminal::disable_raw_mode()?;801	}802803	let success = match status {804		Ok(s) => s.success(),805		Err(e) if e.kind() == io::ErrorKind::NotFound => {806			bail!("editor not found")807		}808		Err(e) => bail!("editor spawn error: {e}"),809	};810811	let mut file = std::fs::read(&abs_path).context("read editor output")?;812	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {813		todo!();814	};815	todo!();816817	// Ok((success, abs_path))818}
after · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24};2526#[derive(Parser)]27pub enum Secret {28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		machines: Vec<String>,36		/// Override secret if already present37		#[clap(long)]38		force: bool,39		/// Secret public part40		#[clap(long)]41		public: Option<String>,42		/// How to name public secret part43		#[clap(long, default_value = "public")]44		public_name: String,45		/// Load public part from specified file46		#[clap(long)]47		public_file: Option<PathBuf>,4849		/// Create a notification on secret expiration50		#[clap(long)]51		expires_at: Option<DateTime<Utc>>,5253		/// Secret with this name already exists, override its value while keeping the same owners.54		#[clap(long)]55		re_add: bool,5657		#[clap(default_value = "secret")]58		part_name: String,59	},60	/// Add secret, data should be provided in stdin61	Add {62		/// Secret name63		name: String,64		/// Secret owners65		machine: String,66		/// Override secret if already present67		#[clap(long)]68		force: bool,69		/// Secret public part70		#[clap(long)]71		public: Option<String>,72		/// How to name public secret part73		#[clap(long, default_value = "public")]74		public_name: String,75		/// Load public part from specified file76		#[clap(long)]77		public_file: Option<PathBuf>,7879		#[clap(default_value = "secret")]80		part_name: String,81	},82	/// Read secret from remote host, requires sudo on said host83	Read {84		name: String,85		machine: String,8687		#[clap(default_value = "secret")]88		part_name: String,89	},90	UpdateShared {91		name: String,9293		#[clap(long)]94		machines: Option<Vec<String>>,9596		#[clap(long)]97		add_machines: Vec<String>,98		#[clap(long)]99		remove_machines: Vec<String>,100101		/// Which host should we use to decrypt102		#[clap(long)]103		prefer_identities: Vec<String>,104105		#[clap(default_value = "secret")]106		part_name: String,107	},108	Regenerate {109		/// Which host should we use to decrypt, in case if reencryption is required, without110		/// regeneration111		#[clap(long)]112		prefer_identities: Vec<String>,113	},114	List {},115	Edit {116		name: String,117		machine: String,118119		#[clap(default_value = "secret")]120		part: String,121122		#[clap(long)]123		add: bool,124	},125}126127#[tracing::instrument(skip(config, secret, field, prefer_identities))]128async fn update_owner_set(129	secret_name: &str,130	config: &Config,131	mut secret: FleetSharedSecret,132	field: Value,133	updated_set: &[String],134	prefer_identities: &[String],135) -> Result<FleetSharedSecret> {136	let original_set = secret.owners.clone();137138	let set = original_set.iter().collect::<BTreeSet<_>>();139	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();140141	if set == expected_set {142		info!("no need to update owner list, it is already correct");143		return Ok(secret);144	}145146	let should_regenerate = if set.difference(&expected_set).next().is_some() {147		// TODO: Remove this warning for revokable secrets.148		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");149		nix_go_json!(field.regenerateOnOwnerRemoved)150	} else if expected_set.difference(&set).next().is_some() {151		nix_go_json!(field.regenerateOnOwnerAdded)152	} else {153		false154	};155156	if should_regenerate {157		info!("secret is owner-dependent, will regenerate");158		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;159		Ok(generated)160	} else {161		let identity_holder = if !prefer_identities.is_empty() {162			prefer_identities163				.iter()164				.find(|i| original_set.iter().any(|s| s == *i))165		} else {166			secret.owners.first()167		};168		let Some(identity_holder) = identity_holder else {169			bail!("no available holder found");170		};171172		for (part_name, part) in secret.secret.parts.iter_mut() {173			let _span = info_span!("part reencryption", part_name);174			if !part.raw.encrypted {175				continue;176			}177			let host = config.host(identity_holder).await?;178			let encrypted = host179				.reencrypt(part.raw.clone(), updated_set.to_vec())180				.await?;181			part.raw = encrypted;182		}183184		secret.owners = updated_set.to_vec();185		Ok(secret)186	}187}188189#[derive(Deserialize)]190#[serde(rename_all = "camelCase")]191enum GeneratorKind {192	Impure,193	Pure,194}195196async fn generate_pure(197	_config: &Config,198	_display_name: &str,199	_secret: Value,200	_default_generator: Value,201	_owners: &[String],202) -> Result<FleetSecret> {203	bail!("pure generators are broken for now")204}205async fn generate_impure(206	config: &Config,207	_display_name: &str,208	secret: Value,209	default_generator: Value,210	owners: &[String],211) -> Result<FleetSecret> {212	let generator = nix_go!(secret.generator);213	let on: Option<String> = nix_go_json!(default_generator.impureOn);214215	let host = if let Some(on) = &on {216		config.host(on).await?217	} else {218		config.local_host()219	};220	let on_pkgs = host.pkgs().await?;221	let call_package = nix_go!(on_pkgs.callPackage);222	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);223224	let mut recipients = Vec::new();225	for owner in owners {226		let key = config.key(owner).await?;227		recipients.push(key);228	}229	let encrypt = nix_go!(mk_encrypt_secret(Obj {230		recipients: { recipients },231	}));232233	let generator = nix_go!(call_package(generator)(Obj {234		encrypt,235		// rustfmt_please_newline236	}));237238	let generator = generator.build().await?;239	let generator = generator240		.get("out")241		.ok_or_else(|| anyhow!("missing generateImpure out"))?;242	let generator = host.remote_derivation(generator).await?;243244	let out_parent = host.mktemp_dir().await?;245	let out = format!("{out_parent}/out");246247	let mut gen = host.cmd(generator).await?;248	gen.env("out", &out);249	if on.is_none() {250		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.251		let project_path: String = config252			.directory253			.clone()254			.into_os_string()255			.into_string()256			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;257		gen.env("FLEET_PROJECT", project_path);258	}259	gen.run().await.context("impure generator")?;260261	{262		let marker = host.read_file_text(format!("{out}/marker")).await?;263		ensure!(marker == "SUCCESS", "generation not succeeded");264	}265266	let mut parts = BTreeMap::new();267	for part in host.read_dir(&out).await? {268		if part == "created_at" || part == "expired_at" || part == "marker" {269			continue;270		}271		let contents: SecretData = host272			.read_file_text(format!("{out}/{part}"))273			.await?274			.parse()275			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;276		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });277	}278279	let created_at = host.read_file_value(format!("{out}/created_at")).await?;280	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();281282	Ok(FleetSecret {283		created_at,284		expires_at,285		parts,286	})287}288async fn generate(289	config: &Config,290	display_name: &str,291	secret: Value,292	owners: &[String],293) -> Result<FleetSecret> {294	let generator = nix_go!(secret.generator);295	// Can't properly check on nix module system level296	{297		let gen_ty = generator.type_of().await?;298		if gen_ty == "null" {299			bail!("secret has no generator defined, can't automatically generate it.");300		}301		if gen_ty != "lambda" {302			bail!("generator should be lambda, got {gen_ty}");303		}304	}305	let default_pkgs = &config.default_pkgs;306	let default_call_package = nix_go!(default_pkgs.callPackage);307	// Generators provide additional information in passthru, to access308	// passthru we should call generator, but information about where this generator is supposed to build309	// is located in passthru... Thus evaluating generator on host.310	//311	// Maybe it is also possible to do some magic with __functor?312	//313	// I don't want to make modules always responsible for additional secret data anyway,314	// so it should be in derivation, and not in the secret data itself.315	let default_generator = nix_go!(default_call_package(generator)(Obj {316		encrypt: { "exit 1" },317		// rustfmt_please_newline318	}));319320	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);321322	match kind {323		GeneratorKind::Impure => {324			generate_impure(config, display_name, secret, default_generator, owners).await325		}326		GeneratorKind::Pure => {327			generate_pure(config, display_name, secret, default_generator, owners).await328		}329	}330}331async fn generate_shared(332	config: &Config,333	display_name: &str,334	secret: Value,335	expected_owners: Vec<String>,336) -> Result<FleetSharedSecret> {337	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);338	Ok(FleetSharedSecret {339		secret: generate(config, display_name, secret, &expected_owners).await?,340		owners: expected_owners,341	})342}343344async fn parse_public(345	public: Option<String>,346	public_file: Option<PathBuf>,347) -> Result<Option<SecretData>> {348	Ok(match (public, public_file) {349		(Some(v), None) => Some(SecretData {350			data: v.into(),351			encrypted: false,352		}),353		(None, Some(v)) => Some(SecretData {354			data: read(v).await?,355			encrypted: false,356		}),357		(Some(_), Some(_)) => {358			bail!("only public or public_file should be set")359		}360		(None, None) => None,361	})362}363364async fn parse_secret() -> Result<Option<Vec<u8>>> {365	let mut input = vec![];366	io::stdin().read_to_end(&mut input)?;367	if input.is_empty() {368		Ok(None)369	} else {370		Ok(Some(input))371	}372}373374fn parse_machines(375	initial: Vec<String>,376	machines: Option<Vec<String>>,377	mut add_machines: Vec<String>,378	mut remove_machines: Vec<String>,379) -> Result<Vec<String>> {380	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {381		bail!("no operation");382	}383384	let initial_machines = initial.clone();385	let mut target_machines = initial;386	info!("Currently encrypted for {initial_machines:?}");387388	// ensure!(machines.is_some() || !add_machines.is_empty() || )389	if let Some(machines) = machines {390		ensure!(391			add_machines.is_empty() && remove_machines.is_empty(),392			"can't combine --machines and --add-machines/--remove-machines"393		);394		let target = initial_machines.iter().collect::<HashSet<_>>();395		let source = machines.iter().collect::<HashSet<_>>();396		for removed in target.difference(&source) {397			remove_machines.push((*removed).clone());398		}399		for added in source.difference(&target) {400			add_machines.push((*added).clone());401		}402	}403404	for machine in &remove_machines {405		let mut removed = false;406		while let Some(pos) = target_machines.iter().position(|m| m == machine) {407			target_machines.swap_remove(pos);408			removed = true;409		}410		if !removed {411			warn!("secret is not enabled for {machine}");412		}413	}414	for machine in &add_machines {415		if target_machines.iter().any(|m| m == machine) {416			warn!("secret is already added to {machine}");417		} else {418			target_machines.push(machine.to_owned());419		}420	}421	if !remove_machines.is_empty() {422		// TODO: maybe force secret regeneration?423		// Not that useful without revokation.424		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");425	}426	Ok(target_machines)427}428impl Secret {429	pub async fn run(self, config: &Config) -> Result<()> {430		match self {431			Secret::ForceKeys => {432				for host in config.list_hosts().await? {433					if config.should_skip(&host.name) {434						continue;435					}436					config.key(&host.name).await?;437				}438			}439			Secret::AddShared {440				mut machines,441				name,442				force,443				public,444				public_name,445				public_file,446				expires_at,447				re_add,448				part_name,449			} => {450				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).451452				let exists = config.has_shared(&name);453				if exists && !force && !re_add {454					bail!("secret already defined");455				}456				if re_add {457					// Fixme: use clap to limit this usage458					ensure!(!force, "--force and --readd are not compatible");459					ensure!(exists, "secret doesn't exists");460					ensure!(461						machines.is_empty(),462						"you can't use machines argument for --readd"463					);464					let shared = config.shared_secret(&name)?;465					machines = shared.owners;466				}467468				let recipients = config.recipients(machines.clone()).await?;469470				let mut parts = BTreeMap::new();471472				let mut input = vec![];473				io::stdin().read_to_end(&mut input)?;474475				if !input.is_empty() {476					let encrypted = encrypt_secret_data(recipients, input)477						.ok_or_else(|| anyhow!("no recipients provided"))?;478					parts.insert(part_name, FleetSecretPart { raw: encrypted });479				}480481				if let Some(public) = parse_public(public, public_file).await? {482					parts.insert(public_name, FleetSecretPart { raw: public });483				}484485				config.replace_shared(486					name,487					FleetSharedSecret {488						owners: machines,489						secret: FleetSecret {490							created_at: Utc::now(),491							expires_at,492							parts,493						},494					},495				);496			}497			Secret::Add {498				machine,499				name,500				force,501				public,502				public_name,503				public_file,504				part_name,505			} => {506				if config.has_secret(&machine, &name) && !force {507					bail!("secret already defined");508				}509510				let mut parts = BTreeMap::new();511512				if let Some(secret) = parse_secret().await? {513					let recipient = config.recipient(&machine).await?;514					let encrypted =515						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");516					parts.insert(part_name, FleetSecretPart { raw: encrypted });517				}518519				if let Some(public) = parse_public(public, public_file).await? {520					parts.insert(public_name, FleetSecretPart { raw: public });521				};522523				config.insert_secret(524					&machine,525					name,526					FleetSecret {527						created_at: Utc::now(),528						expires_at: None,529						parts,530					},531				);532			}533			#[allow(clippy::await_holding_refcell_ref)]534			Secret::Read {535				name,536				machine,537				part_name,538			} => {539				let secret = config.host_secret(&machine, &name)?;540				let Some(secret) = secret.parts.get(&part_name) else {541					bail!("no part {part_name} in secret {name}");542				};543				let data = if secret.raw.encrypted {544					let host = config.host(&machine).await?;545					host.decrypt(secret.raw.clone()).await?546				} else {547					secret.raw.data.clone()548				};549550				stdout().write_all(&data)?;551			}552			Secret::UpdateShared {553				name,554				machines,555				add_machines,556				remove_machines,557				prefer_identities,558				part_name,559			} => {560				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).561562				let secret = config.shared_secret(&name)?;563				if secret.secret.parts.get(&part_name).is_none() {564					bail!("no secret");565				}566567				let initial_machines = secret.owners.clone();568				let target_machines = parse_machines(569					initial_machines.clone(),570					machines,571					add_machines,572					remove_machines,573				)?;574575				if target_machines.is_empty() {576					info!("no machines left for secret, removing it");577					config.remove_shared(&name);578					return Ok(());579				}580581				let config_field = &config.config_unchecked_field;582				let field = nix_go!(config_field.sharedSecrets[{ name }]);583584				let updated = update_owner_set(585					&name,586					config,587					secret,588					field,589					&target_machines,590					&prefer_identities,591				)592				.await?;593				config.replace_shared(name, updated);594			}595			Secret::Regenerate { prefer_identities } => {596				info!("checking for secrets to regenerate");597				{598					let _span = info_span!("shared").entered();599					let expected_shared_set = config600						.list_configured_shared()601						.await?602						.into_iter()603						.collect::<HashSet<_>>();604					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();605					for missing in expected_shared_set.difference(&shared_set) {606						let config_field = &config.config_unchecked_field;607						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);608						let expected_owners: Option<Vec<String>> =609							nix_go_json!(secret.expectedOwners);610						let Some(expected_owners) = expected_owners else {611							// TODO: Might still need to regenerate612							continue;613						};614						info!("generating secret: {missing}");615						let shared = generate_shared(config, missing, secret, expected_owners)616							.in_current_span()617							.await?;618						config.replace_shared(missing.to_string(), shared)619					}620				}621				for host in config.list_hosts().await? {622					if config.should_skip(&host.name) {623						continue;624					}625626					let _span = info_span!("host", host = host.name).entered();627					let expected_set = host628						.list_configured_secrets()629						.in_current_span()630						.await?631						.into_iter()632						.collect::<HashSet<_>>();633					let stored_set = config634						.list_secrets(&host.name)635						.into_iter()636						.collect::<HashSet<_>>();637					for missing in expected_set.difference(&stored_set) {638						info!("generating secret: {missing}");639						let secret = host.secret_field(missing).in_current_span().await?;640						let generated =641							match generate(config, missing, secret, &[host.name.clone()])642								.in_current_span()643								.await644							{645								Ok(v) => v,646								Err(e) => {647									error!("{e:?}");648									continue;649								}650							};651						config.insert_secret(&host.name, missing.to_string(), generated)652					}653				}654				let mut to_remove = Vec::new();655				for name in &config.list_shared() {656					info!("updating secret: {name}");657					let data = config.shared_secret(name)?;658					let config_field = &config.config_unchecked_field;659					let expected_owners: Vec<String> =660						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);661					if expected_owners.is_empty() {662						warn!("secret was removed from fleet config: {name}, removing from data");663						to_remove.push(name.to_string());664						continue;665					}666667					let secret = nix_go!(config_field.sharedSecrets[{ name }]);668					config.replace_shared(669						name.to_owned(),670						update_owner_set(671							name,672							config,673							data,674							secret,675							&expected_owners,676							&prefer_identities,677						)678						.await?,679					);680				}681				for k in to_remove {682					config.remove_shared(&k);683				}684			}685			Secret::List {} => {686				let _span = info_span!("loading secrets").entered();687				let configured = config.list_configured_shared().await?;688				#[derive(Tabled)]689				struct SecretDisplay {690					#[tabled(rename = "Name")]691					name: String,692					#[tabled(rename = "Owners")]693					owners: String,694				}695				let mut table = vec![];696				for name in configured.iter().cloned() {697					let config = config.clone();698					let expected_owners = config.shared_secret_expected_owners(&name).await?;699					let data = config.shared_secret(&name)?;700					let owners = data701						.owners702						.iter()703						.map(|o| {704							if expected_owners.contains(o) {705								o.green().to_string()706							} else {707								o.red().to_string()708							}709						})710						.collect::<Vec<_>>();711					table.push(SecretDisplay {712						owners: owners.join(", "),713						name,714					})715				}716				info!("loaded\n{}", Table::new(table).to_string())717			}718			Secret::Edit {719				name,720				machine,721				part,722				add,723			} => {724				let secret = config.host_secret(&machine, &name)?;725				if let Some(data) = secret.parts.get(&part) {726					let host = config.host(&machine).await?;727					let secret = host.decrypt(data.raw.clone()).await?;728					String::from_utf8(secret).context("secret is not utf8")?729				} else if add {730					String::new()731				} else {732					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");733				};734			}735		}736		Ok(())737	}738}739740async fn edit_temp_file(741	builder: tempfile::Builder<'_, '_>,742	r: Vec<u8>,743	header: &str,744	comment: &str,745) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {746	if !stdin().is_tty() {747		// TODO: Also try to open /dev/tty directly?748		bail!("stdin is not tty, can't open editor");749	}750751	use std::fmt::Write;752	let mut file = builder.tempfile()?;753754	let mut full_header = String::new();755	let mut had = false;756	for line in header.trim_end().lines() {757		had = true;758		writeln!(&mut full_header, "{comment}{line}")?;759	}760	if had {761		writeln!(&mut full_header, "{}", comment.trim_end())?;762	}763	writeln!(764		&mut full_header,765		"{comment}Do not touch this header! It will be removed automatically"766	)?;767768	file.write_all(full_header.as_bytes())?;769	file.write_all(&r)?;770771	let abs_path = file.into_temp_path();772	let editor = std::env::var_os("VISUAL")773		.or_else(|| std::env::var_os("EDITOR"))774		.unwrap_or_else(|| "vi".into());775	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())776		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;777	let editor_args = editor_args778		.into_iter()779		.map(|v| {780			// Only ASCII subsequences are replaced781			unsafe { OsString::from_encoded_bytes_unchecked(v) }782		})783		.collect_vec();784	let Some((editor, args)) = editor_args.split_first() else {785		bail!("EDITOR env var has no command");786	};787	let mut command = Command::new(editor);788	command.args(args);789790	let path_arg = abs_path.canonicalize()?;791792	// TODO: Save full state, using tcget/_getmode/_setmode793	let was_raw = terminal::is_raw_mode_enabled()?;794	terminal::enable_raw_mode()?;795796	let status = command.arg(path_arg).status().await;797798	if !was_raw {799		terminal::disable_raw_mode()?;800	}801802	let success = match status {803		Ok(s) => s.success(),804		Err(e) if e.kind() == io::ErrorKind::NotFound => {805			bail!("editor not found")806		}807		Err(e) => bail!("editor spawn error: {e}"),808	};809810	let mut file = std::fs::read(&abs_path).context("read editor output")?;811	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {812		todo!();813	};814	todo!();815816	// Ok((success, abs_path))817}
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -12,15 +12,14 @@
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use clap::{ArgGroup, Parser};
 use fleet_shared::SecretData;
+use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};
 use openssh::SessionBuilder;
 use serde::de::DeserializeOwned;
 use tempfile::NamedTempFile;
 
 use crate::{
-	better_nix_eval::{Field, NixSessionPool},
 	command::MyCommand,
 	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
-	nix_go, nix_go_json,
 };
 
 pub struct FleetConfigInternals {
@@ -30,12 +29,12 @@
 	pub data: Mutex<FleetData>,
 	pub nix_args: Vec<OsString>,
 	/// fleet_config.config
-	pub config_field: Field,
+	pub config_field: Value,
 	/// fleet_config.unchecked.config
-	pub config_unchecked_field: Field,
+	pub config_unchecked_field: Value,
 
 	/// import nixpkgs {system = local};
-	pub default_pkgs: Field,
+	pub default_pkgs: Value,
 }
 
 #[derive(Clone)]
@@ -55,7 +54,7 @@
 	pub local: bool,
 	pub session: OnceLock<Arc<openssh::Session>>,
 
-	pub nixos_config: Option<Field>,
+	pub nixos_config: Option<Value>,
 }
 impl ConfigHost {
 	async fn open_session(&self) -> Result<Arc<openssh::Session>> {
@@ -201,7 +200,7 @@
 		}
 		Ok(out)
 	}
-	pub async fn secret_field(&self, name: &str) -> Result<Field> {
+	pub async fn secret_field(&self, name: &str) -> Result<Value> {
 		let Some(nixos) = &self.nixos_config else {
 			bail!("host is virtual and has no secrets");
 		};
@@ -209,7 +208,7 @@
 	}
 
 	/// Packages for this host, resolved with nixpkgs overlays
-	pub async fn pkgs(&self) -> Result<Field> {
+	pub async fn pkgs(&self) -> Result<Value> {
 		let Some(nixos) = &self.nixos_config else {
 			return Ok(self.config.default_pkgs.clone());
 		};
@@ -261,7 +260,7 @@
 		}
 		Ok(out)
 	}
-	pub async fn system_config(&self, host: &str) -> Result<Field> {
+	pub async fn system_config(&self, host: &str) -> Result<Value> {
 		let fleet_field = &self.config_unchecked_field;
 		Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))
 	}
@@ -275,7 +274,7 @@
 	/// Shared secrets configured in fleet.nix or in flake
 	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
 		let config_field = &self.config_unchecked_field;
-		nix_go!(config_field.sharedSecrets).list_fields().await
+		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)
 	}
 	/// Shared secrets configured in fleet.nix
 	pub fn list_shared(&self) -> Vec<String> {
@@ -389,13 +388,13 @@
 		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;
 		let root_field = pool.get().await?;
 
-		let builtins_field = Field::field(root_field.clone(), "builtins").await?;
+		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;
 		if self.local_system == "detect" {
 			self.local_system = nix_go_json!(builtins_field.currentSystem);
 		}
 		let local_system = self.local_system.clone();
 
-		let fleet_root = Field::field(root_field, "fleetConfigurations").await?;
+		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;
 		let fleet_field = nix_go!(fleet_root.default);
 
 		let config_field = nix_go!(fleet_field.config);
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -1,5 +1,5 @@
 #![recursion_limit = "512"]
-#![feature(try_blocks, lint_reasons)]
+#![feature(try_blocks)]
 
 pub(crate) mod cmds;
 pub(crate) mod command;
@@ -173,6 +173,8 @@
 	setup_logging();
 	if let Err(e) = main_real().await {
 		// If I remove this line, the next error!() line gets eaten.
+		// This is a bug in indicatif, it needs to be fixed
+		#[cfg(feature = "indicatif")]
 		info!("fixme: this line gets eaten by tracing-indicatif on levels info+");
 		error!("{e:#}");
 		return ExitCode::FAILURE;
@@ -181,7 +183,7 @@
 }
 
 async fn main_real() -> Result<()> {
-	let _ = better_nix_eval::TOKIO_RUNTIME.set(tokio::runtime::Handle::current());
+	nix_eval::init_tokio();
 
 	let nix_args = std::env::var_os("NIX_ARGS")
 		.map(|a| extra_args::parse_os(&a))
addedcmds/generator-helper/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/cmds/generator-helper/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fleet-generator-helper"
+edition = "2021"
+version.workspace = true
+
+[dependencies]
+age.workspace = true
+anyhow.workspace = true
+clap.workspace = true
+ed25519-dalek = { version = "2.1.1", features = ["rand_core"] }
+fleet-shared.workspace = true
+rand = "0.8.5"
addedcmds/generator-helper/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/generator-helper/src/main.rs
@@ -0,0 +1,204 @@
+use std::{
+	fs,
+	io::{self, stdout, Cursor, Read, Write},
+	path::PathBuf,
+	str::FromStr,
+};
+
+use age::Recipient;
+use anyhow::{anyhow, bail, ensure, Context, Result};
+use clap::Parser;
+use ed25519_dalek::SigningKey;
+use fleet_shared::SecretData;
+use rand::{
+	distributions::{Alphanumeric, DistString, Distribution, Uniform},
+	rngs::OsRng,
+	thread_rng, Rng,
+};
+
+fn write_output(out: &str, data: impl AsRef<[u8]>, stdout_marker: &mut bool) -> Result<()> {
+	let data = data.as_ref();
+	if out == "-" {
+		let mut stdout = stdout();
+		if *stdout_marker {
+			stdout.write_all(&[b'\n'])?;
+		}
+		*stdout_marker = true;
+		stdout.write_all(data)?;
+	} else {
+		fs::write(out, data)?;
+	};
+	Ok(())
+}
+
+#[derive(Parser)]
+enum Generate {
+	/// Generate public, private keys without wrapping, in standard ed25519 schema
+	/// (64 bytes private (due to merge with private), 32 bytes public)
+	Ed25519 {
+		public: String,
+		private: String,
+		/// Private key should be just the private key (32 bytes), not standard private+public.
+		#[arg(long)]
+		no_embed_public: bool,
+	},
+	Password {
+		output: String,
+		size: usize,
+		#[arg(long, short = 'n')]
+		no_symbols: bool,
+	},
+}
+
+#[derive(Parser)]
+enum Opts {
+	/// Encode public part from stdin.
+	Public {
+		#[arg(long)]
+		allow_empty: bool,
+	},
+	/// Encrypt private part from stdin.
+	Private {
+		#[arg(long)]
+		allow_empty: bool,
+		#[arg(short = 'r')]
+		recipient: Vec<String>,
+	},
+	/// Generate keys in well-known schemas.
+	///
+	/// Note that this command is only intended to be used in fleet secret generator,
+	/// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.
+	#[command(subcommand)]
+	Generate(Generate),
+	// Generate {
+	// 	kind: GenerateKind,
+	// 	/// Different generators generate different number of files, you need to specify number of outputs corresponding to the generator.
+	// 	#[arg(short = 'o')]
+	// 	outputs: Vec<String>,
+	// },
+}
+
+fn parse_stdin() -> Result<Option<Vec<u8>>> {
+	let mut input = vec![];
+	io::stdin().read_to_end(&mut input)?;
+	if input.is_empty() {
+		Ok(None)
+	} else {
+		Ok(Some(input))
+	}
+}
+pub fn encrypt_secret_data(
+	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
+	data: Vec<u8>,
+) -> Option<SecretData> {
+	let mut encrypted = vec![];
+	let recipients = recipients
+		.into_iter()
+		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+		.collect::<Vec<_>>();
+	let mut encryptor = age::Encryptor::with_recipients(recipients)?
+		.wrap_output(&mut encrypted)
+		.expect("in memory write");
+	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
+	encryptor.finish().expect("in memory flush");
+	Some(SecretData {
+		data: encrypted,
+		encrypted: true,
+	})
+}
+
+fn main() -> Result<()> {
+	let opts = Opts::parse();
+	// Assumed to be secure, seeded from secure OsRng+reseeded.
+	let mut rng = thread_rng();
+
+	match opts {
+		Opts::Public { allow_empty } => {
+			let stdin = parse_stdin()?;
+			if stdin.is_none() && !allow_empty {
+				bail!("empty stdin input is not allowed unless --allow-empty is set");
+			}
+			let stdin = stdin.unwrap_or_default();
+			io::stdout().write_all(
+				SecretData {
+					data: stdin,
+					encrypted: false,
+				}
+				.to_string()
+				.as_bytes(),
+			)?;
+		}
+		Opts::Private {
+			allow_empty,
+			recipient,
+		} => {
+			let stdin = parse_stdin()?;
+			if stdin.is_none() && !allow_empty {
+				bail!("empty stdin input is not allowed unless --allow-empty is set");
+			}
+			let stdin = stdin.unwrap_or_default();
+			if recipient.is_empty() {
+				bail!("recipient list is empty");
+			}
+			let out = encrypt_secret_data(
+				recipient
+					.into_iter()
+					.map(|r| age::ssh::Recipient::from_str(&r))
+					.collect::<Result<Vec<age::ssh::Recipient>, age::ssh::ParseRecipientKeyError>>()
+					.map_err(|e| anyhow!("parse recipients: {e:?}"))?,
+				stdin,
+			)
+			.expect("got recipients");
+			io::stdout().write_all(out.to_string().as_bytes())?;
+		}
+		Opts::Generate(gen) => {
+			let mut stdout_marker: bool = false;
+			match gen {
+				Generate::Ed25519 {
+					public,
+					private,
+					no_embed_public,
+				} => {
+					let key = SigningKey::generate(&mut rng).to_keypair_bytes();
+
+					write_output(&public, &key[32..], &mut stdout_marker).context("public")?;
+					write_output(
+						&private,
+						&key[..{
+							if no_embed_public {
+								32
+							} else {
+								64
+							}
+						}],
+						&mut stdout_marker,
+					)
+					.context("private")?;
+				}
+				Generate::Password {
+					size,
+					no_symbols,
+					output,
+				} => {
+					ensure!(
+						size >= 6,
+						"misconfiguration? password is shorter than 6 chars"
+					);
+					let out = if no_symbols {
+						Alphanumeric.sample_string(&mut rng, size)
+					} else {
+						// Alphabet of Alphanumberic + symbols
+						const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
+						let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len());
+						(0..size)
+							.map(|_| uniform.sample(&mut rng))
+							.map(|i| GEN_ASCII_SYMBOLS[i] as char)
+							.collect::<String>()
+					};
+					write_output(&output, out, &mut stdout_marker)?;
+				}
+			}
+		}
+	}
+	Ok(())
+}
modifiedcmds/install-secrets/Cargo.tomldiffbeforeafterboth
--- a/cmds/install-secrets/Cargo.toml
+++ b/cmds/install-secrets/Cargo.toml
@@ -6,18 +6,13 @@
 
 
 [dependencies]
-age = { version = "0.10.0", features = ["ssh"] }
-anyhow = "1.0.79"
-tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-tracing = "0.1"
-nix = {version = "0.27.1", features = ["user", "fs"]}
-serde = { version = "1.0.196", features = ["derive"] }
-serde_json = "1.0.113"
-clap = { version = ">=4.4, <4.5", features = [
-	"derive",
-	"env",
-	"wrap_help",
-	"unicode",
-] }
-tempfile = "3.10.0"
+clap.workspace = true
 fleet-shared.workspace = true
+age.workspace = true
+anyhow.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tempfile.workspace = true
+nix.workspace = true
modifiedcmds/remowt-agent/Cargo.tomldiffbeforeafterboth
--- a/cmds/remowt-agent/Cargo.toml
+++ b/cmds/remowt-agent/Cargo.toml
@@ -6,3 +6,5 @@
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
+iroh-net = "0.17.0"
+tracing.workspace = true
addedcrates/nix-eval/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "nix-eval"
+edition = "2021"
+version.workspace = true
+
+[dependencies]
+better-command.workspace = true
+futures = "0.3.30"
+itertools = "0.13.0"
+nixlike.workspace = true
+r2d2 = "0.8.10"
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror = "1.0.61"
+tokio = { workspace = true, features = ["process", "io-util"] }
+tokio-util = { version = "0.7.11", features = ["codec"] }
+tracing.workspace = true
+unindent = "0.2.3"
addedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/src/lib.rs
@@ -0,0 +1,32 @@
+//! This whole library should be replaced with either binding to nix libexpr,
+//! or with tvix (once it is able to build NixOS).
+//!
+//! Current api is awful, little effort was put into this implementation.
+
+use std::sync::Arc;
+
+pub use pool::NixSessionPool;
+use pool::NixSessionPoolInner;
+use r2d2::PooledConnection;
+pub use session::{Error, Result};
+pub use value::{Index, Value};
+
+mod pool;
+mod session;
+mod value;
+// Contains macros helpers
+#[doc(hidden)]
+pub mod macros;
+
+#[derive(Clone)]
+pub struct NixSession(pub(crate) Arc<tokio::sync::Mutex<PooledConnection<NixSessionPoolInner>>>);
+
+impl NixSession {
+	fn ptr_eq(a: &Self, b: &Self) -> bool {
+		Arc::ptr_eq(&a.0, &b.0)
+	}
+}
+
+pub fn init_tokio() {
+	let _ = pool::TOKIO_RUNTIME.set(tokio::runtime::Handle::current());
+}
addedcrates/nix-eval/src/macros.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/src/macros.rs
@@ -0,0 +1,183 @@
+use serde::Serialize;
+
+use crate::{NixSession, Value};
+
+#[derive(Clone)]
+pub struct NixExprBuilder {
+	pub(crate) out: String,
+	used_fields: Vec<Value>,
+}
+impl NixExprBuilder {
+	pub fn object() -> Self {
+		NixExprBuilder {
+			out: "{ ".to_owned(),
+			used_fields: Vec::new(),
+		}
+	}
+	pub fn string(s: &str) -> Self {
+		NixExprBuilder {
+			out: nixlike::serialize(s)
+				.expect("no problems with serializing_string")
+				.trim_end()
+				.to_owned(),
+			used_fields: Vec::new(),
+		}
+	}
+	pub fn serialized(v: impl Serialize) -> Self {
+		let serialized = nixlike::serialize(v).expect("invalid value for apply");
+		Self {
+			out: serialized.trim_end().to_owned(),
+			used_fields: Vec::new(),
+		}
+	}
+	pub fn value(f: Value) -> Self {
+		Self {
+			out: format!("sess_field_{}", f.session_field_id()),
+			used_fields: vec![f],
+		}
+	}
+	pub fn end_obj(&mut self) {
+		self.out.push('}');
+	}
+	pub fn obj_key(&mut self, name: Self, value: Self) {
+		self.out.push_str(r#""${"#);
+		self.extend(name);
+		self.out.push_str(r#"}" = "#);
+		self.extend(value);
+		self.out.push_str("; ");
+	}
+
+	pub fn extend(&mut self, e: Self) {
+		self.out.push_str(&e.out);
+		self.used_fields.extend(e.used_fields);
+	}
+
+	#[allow(dead_code)]
+	pub fn session(&self) -> NixSession {
+		let mut session = None;
+		for ele in &self.used_fields {
+			if session.is_none() {
+				session = Some(ele.session());
+				continue;
+			}
+			let session = session.as_ref().expect("checked");
+			let ele_sess = ele.session();
+			assert!(
+				NixSession::ptr_eq(session, &ele_sess),
+				"can't mix fields from different session"
+			);
+		}
+		session.expect("expr without fields used")
+	}
+	#[allow(dead_code)]
+	pub fn index_attr(&mut self, s: &str) {
+		let escaped = nixlike::serialize(s).expect("string");
+		self.out.push('.');
+		self.out.push_str(escaped.trim_end());
+	}
+}
+
+#[macro_export]
+macro_rules! nix_expr_inner {
+	//(@munch_object FIXME: value should be arbitrary nix_expr_inner input... Time to write proc-macro?
+	(@obj($o:ident) $field:ident, $($tt:tt)*) => {{
+		$o.obj_key(
+			NixExprBuilder::string(stringify!($field)),
+			NixExprBuilder::value($field),
+		);
+		nix_expr_inner!(@obj($o) $($tt)*);
+	}};
+	(@obj($o:ident) $field:ident: $v:block, $($tt:tt)*) => {{
+		$o.obj_key(
+			NixExprBuilder::string(stringify!($field)),
+			NixExprBuilder::serialized(&$v),
+		);
+		nix_expr_inner!(@obj($o) $($tt)*);
+	}};
+	(@obj($o:ident)) => {{}};
+	(Obj { $($tt:tt)* }) => {{
+		use $crate::{macros::NixExprBuilder, nix_expr_inner};
+		let mut out = NixExprBuilder::object();
+		nix_expr_inner!(@obj(out) $($tt)*);
+		out.end_obj();
+		out
+	}};
+	(@field($o:ident) . $var:ident $($tt:tt)*) => {{
+		$o.index_attr(stringify!($var));
+		nix_expr_inner!(@field($o) $($tt)*);
+	}};
+	(@field($o:ident) [{ $v:expr }] $($tt:tt)*) => {{
+		$o.push(Index::attr(&$v));
+		nix_expr_inner!(@o($o) $($tt)*);
+	}};
+	(@field($o:ident) [ $($var:tt)+ ] $($tt:tt)*) => {{
+		$o.push(Index::Expr($crate::nix_expr_inner!($($var)+)));
+		nix_expr_inner!(@o($o) $($tt)*);
+	}};
+	(@field($o:ident) ($($var:tt)*) $($tt:tt)*) => {
+		$o.push(Index::ExprApply($crate::nix_expr_inner!($($var)+)));
+		nix_expr_inner!(@o($o) $($tt)*);
+	};
+	(@field($o:ident)) => {};
+	($field:ident $($tt:tt)*) => {{
+		use $crate::{macros::NixExprBuilder, nix_expr_inner};
+		// might be used if indexed
+		#[allow(unused_mut)]
+		let mut out = NixExprBuilder::value($field.clone());
+		nix_expr_inner!(@field(out) $($tt)*);
+		out
+	}};
+	($v:literal) => {{
+		use $crate::macros::NixExprBuilder;
+		NixExprBuilder::string($v)
+	}};
+	({$v:expr}) => {{
+		use $crate::macros::NixExprBuilder;
+		NixExprBuilder::serialized(&$v)
+	}}
+}
+#[macro_export]
+macro_rules! nix_expr {
+	($($tt:tt)+) => {{
+		use $crate::{macros::{NixExprBuilder}, Value, nix_expr_inner};
+		let expr = nix_expr_inner!($($tt)+);
+		Field::new(expr.session(), expr.out)
+	}};
+}
+
+#[macro_export]
+macro_rules! nix_go {
+	(@o($o:ident) . $var:ident $($tt:tt)*) => {{
+		$o.push(Index::attr(stringify!($var)));
+		nix_go!(@o($o) $($tt)*);
+	}};
+	(@o($o:ident) [{ $v:expr }] $($tt:tt)*) => {{
+		$o.push(Index::attr(&$v));
+		nix_go!(@o($o) $($tt)*);
+	}};
+	(@o($o:ident) [ $($var:tt)+ ] $($tt:tt)*) => {{
+		$o.push(Index::Expr($crate::nix_expr_inner!($($var)+)));
+		nix_go!(@o($o) $($tt)*);
+	}};
+	(@o($o:ident) ($($var:tt)*) $($tt:tt)*) => {
+		$o.push(Index::ExprApply($crate::nix_expr_inner!($($var)+)));
+		nix_go!(@o($o) $($tt)*);
+	};
+	(@o($o:ident) | $($var:tt)*) => {
+		$o.push(Index::Pipe($crate::nix_expr_inner!($($var)+)));
+	};
+	(@o($o:ident)) => {};
+	($field:ident $($tt:tt)+) => {{
+		use $crate::{nix_go, Index};
+		let field = $field.clone();
+		let mut out = vec![];
+		nix_go!(@o(out) $($tt)*);
+		field.select(out).await?
+	}}
+}
+#[macro_export]
+macro_rules! nix_go_json {
+	($($tt:tt)*) => {{
+		$crate::nix_go!($($tt)*).as_json().await?
+	}};
+}
addedcrates/nix-eval/src/pool.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/src/pool.rs
@@ -0,0 +1,61 @@
+use std::ffi::OsString;
+use std::sync::{Arc, OnceLock};
+
+use r2d2::Pool;
+
+use crate::session::NixSessionInner;
+use crate::{Error, NixSession, Result};
+
+pub struct NixSessionPool(Pool<NixSessionPoolInner>);
+impl NixSessionPool {
+	pub async fn new(flake: OsString, nix_args: Vec<OsString>) -> Result<Self> {
+		let inner = tokio::task::block_in_place(|| {
+			r2d2::Builder::<NixSessionPoolInner>::new()
+				.min_idle(Some(0))
+				.build(NixSessionPoolInner { flake, nix_args })
+		})?;
+		Ok(Self(inner))
+	}
+	pub async fn get(&self) -> Result<NixSession> {
+		let v = tokio::task::block_in_place(|| self.0.get())?;
+		Ok(NixSession(Arc::new(tokio::sync::Mutex::new(v))))
+	}
+}
+
+pub(crate) struct NixSessionPoolInner {
+	flake: OsString,
+	nix_args: Vec<OsString>,
+}
+
+impl r2d2::ManageConnection for NixSessionPoolInner {
+	type Connection = NixSessionInner;
+	type Error = Error;
+	fn connect(&self) -> std::result::Result<Self::Connection, Self::Error> {
+		let _v = TOKIO_RUNTIME
+			.get()
+			.expect("missed tokio runtime init!")
+			.enter();
+		Ok(futures::executor::block_on(NixSessionInner::new(
+			self.flake.as_os_str(),
+			self.nix_args.iter().map(OsString::as_os_str),
+		))?)
+	}
+
+	fn is_valid(&self, conn: &mut Self::Connection) -> std::result::Result<(), Self::Error> {
+		let _v = TOKIO_RUNTIME
+			.get()
+			.expect("missed tokio runtime init!")
+			.enter();
+		let res = futures::executor::block_on(conn.execute_expression_number("2 + 2"))?;
+		if res != 4 {
+			// just in case, should fail much earlier
+			return Err(Error::SessionInit("misbehaving session"));
+		};
+		Ok(())
+	}
+
+	fn has_broken(&self, _conn: &mut Self::Connection) -> bool {
+		false
+	}
+}
+pub static TOKIO_RUNTIME: OnceLock<tokio::runtime::Handle> = OnceLock::new();
addedcrates/nix-eval/src/session.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/src/session.rs
@@ -0,0 +1,415 @@
+use std::{ffi::OsStr, num::ParseIntError, process::Stdio, sync::Arc};
+
+use better_command::{ClonableHandler, Handler, NixHandler, NoopHandler};
+use futures::StreamExt;
+use itertools::Itertools as _;
+use serde::{de::DeserializeOwned, Deserialize};
+use thiserror::Error;
+use tokio::{
+	io::AsyncWriteExt,
+	process::{ChildStderr, ChildStdin, ChildStdout, Command},
+	select,
+	sync::{mpsc, oneshot, Mutex},
+};
+use tokio_util::codec::{FramedRead, LinesCodec};
+use tracing::{debug, error, warn, Level};
+
+#[derive(Error, Debug)]
+pub enum Error {
+	#[error("failed to create nix repl session: {0}")]
+	SessionInit(&'static str),
+	#[error("unexpected end of output, nix crashed?")]
+	MissingDelimiter,
+
+	#[error("expression did'nt produce any output")]
+	ExpectedOutput,
+	#[error("expression produced output, which is unexpected")]
+	UnexpectedOutput,
+
+	#[error("unexpected expression output type")]
+	InvalidType,
+
+	#[error("failed to build attr {attribute}:\n{error}")]
+	BuildFailed { attribute: String, error: String },
+
+	#[error("output: {0}")]
+	Json(#[from] serde_json::Error),
+	// int outputs are too specific, and should not be used,
+	// thus error is ok to be not informative.
+	#[error("int output: {0}")]
+	Int(ParseIntError),
+	#[error("pool: {0}")]
+	Pool(#[from] r2d2::Error),
+	#[error("io: {0}")]
+	Io(#[from] std::io::Error),
+
+	// TODO: Should be done by wrapper/in different type.
+	#[error("at {0}: {1}")]
+	InContext(String, Box<Self>),
+
+	#[error("error: {0}")]
+	NixError(String),
+}
+impl Error {
+	pub(crate) fn context(self, context: String) -> Self {
+		Self::InContext(context, Box::new(self))
+	}
+}
+pub type Result<T, E = Error> = std::result::Result<T, E>;
+
+enum OutputLine {
+	Out(String),
+	Err(String),
+}
+struct OutputHandler {
+	rx: mpsc::Receiver<OutputLine>,
+	_cancel_handle: oneshot::Receiver<()>,
+}
+impl OutputHandler {
+	fn new(out: ChildStdout, err: ChildStderr) -> Self {
+		let mut out = FramedRead::new(out, LinesCodec::new());
+		let mut err = FramedRead::new(err, LinesCodec::new());
+		let (tx, rx) = mpsc::channel(20);
+		let (mut cancelled, _cancel_handle) = oneshot::channel();
+		tokio::spawn(async move {
+			loop {
+				select! {
+					// We should receive errors earlier than synchronization
+					biased;
+					e = err.next() => {
+						let Some(Ok(e)) = e else {
+							if e.is_some() {
+								error!("bad repl stderr: {e:?}");
+							}
+							continue;
+						};
+						let _ = tx.send(OutputLine::Err(e)).await;
+					}
+					o = out.next() => {
+						let Some(Ok(o)) = o else {
+							if o.is_some() {
+								error!("bad repl stdout: {o:?}");
+							}
+							continue;
+						};
+						let _ = tx.send(OutputLine::Out(o)).await;
+					}
+					// Reader doesn't care about stdout, as this is cancelled.
+					// Error still might be useful, to process leftover span closures?
+					_ = cancelled.closed() => {
+						break;
+					}
+				}
+			}
+		});
+		Self { rx, _cancel_handle }
+	}
+	async fn next(&mut self) -> Option<OutputLine> {
+		self.rx.recv().await
+	}
+}
+
+#[must_use]
+struct ErrorCollector<'i, H> {
+	collected: Vec<String>,
+	inner: &'i mut H,
+}
+impl<'i, H> ErrorCollector<'i, H> {
+	fn new(inner: &'i mut H) -> Self {
+		Self {
+			collected: vec![],
+			inner,
+		}
+	}
+}
+impl<H> ErrorCollector<'_, H> {
+	fn handle_line_inner(&mut self, msg: &str) -> bool {
+		let Some(msg) = msg.strip_prefix("@nix ") else {
+			return false;
+		};
+		#[derive(Deserialize)]
+		struct ErrorAction {
+			action: String,
+			level: u32,
+			msg: String,
+		}
+		let Ok(act) = serde_json::from_str::<ErrorAction>(msg) else {
+			return false;
+		};
+		if act.action != "msg" || act.level != 0 {
+			return false;
+		}
+		self.collected.push(act.msg);
+		true
+	}
+	fn finish(self) -> Result<()> {
+		// fn dedent(s: String) -> String {
+		// 	s.split('\n').filter(|s| !s.trim().is_empty()).map(|v| v.)
+		// }
+		if !self.collected.is_empty() {
+			return Err(Error::NixError(format!(
+				"{}",
+				self.collected
+					.iter()
+					.map(|v| {
+						if let Some(f) = v.strip_prefix("\u{1b}[31;1merror:\u{1b}[0m ") {
+							let v = unindent::unindent(f.trim_start());
+							v.trim().to_owned()
+						} else {
+							v.to_owned()
+						}
+					})
+					.join("\n"),
+			)));
+		}
+		Ok(())
+	}
+	fn flush(self) {
+		for line in self.collected {
+			warn!("{line}");
+		}
+	}
+}
+impl<H: Handler> Handler for ErrorCollector<'_, H> {
+	fn handle_line(&mut self, e: &str) {
+		if self.handle_line_inner(e) {
+			return;
+		}
+		self.inner.handle_line(e)
+	}
+}
+
+pub struct NixSessionInner {
+	full_delimiter: String,
+	nix_handler: ClonableHandler<NixHandler>,
+	out: OutputHandler,
+	stdin: ChildStdin,
+	string_wrapping: (String, String),
+	number_wrapping: (String, String),
+
+	executing_command: Arc<Mutex<()>>,
+
+	next_id: u32,
+	pub(crate) free_list: Vec<u32>,
+}
+
+/// Discover inter-message repl delimiter
+const REPL_DELIMITER: &str = "\"FLEET_MAGIC_REPL_DELIMITER\"";
+/// Discover formatting around strings
+const TRAIN_STRING: &str = "\"TRAIN_STRING\"";
+/// Discover formatting around numbers
+const TRAIN_NUMBER: &str = "13141516";
+// Other types of formatting are not discovered, because they are not used, JSON serialization is used instead
+// Techically, number training is also not required, because numbers can be converted to string too...
+// Eh, I'll remove it later.
+
+impl NixSessionInner {
+	pub(crate) async fn new(
+		flake: &OsStr,
+		extra_args: impl IntoIterator<Item = &OsStr>,
+	) -> Result<Self> {
+		let mut cmd = Command::new("nix");
+		cmd.arg("repl")
+			.arg(flake)
+			.arg("--log-format")
+			.arg("internal-json");
+		for arg in extra_args {
+			cmd.arg(arg);
+		}
+		cmd.stdin(Stdio::piped());
+		cmd.stdout(Stdio::piped());
+		cmd.stderr(Stdio::piped());
+		let cmd = cmd.spawn()?;
+		let stdout = cmd.stdout.unwrap();
+		let stderr = cmd.stderr.unwrap();
+		let mut out = OutputHandler::new(stdout, stderr);
+		let mut stdin = cmd.stdin.unwrap();
+		// Standard repl hello doesn't work with internal-json logger
+		stdin.write_all(REPL_DELIMITER.as_bytes()).await?;
+		stdin.write_all(b"\n").await?;
+		stdin.flush().await?;
+		let nix_handler = NixHandler::default();
+		let mut full_delimiter = None;
+		let mut errors = vec![];
+		while let Some(line) = out.next().await {
+			let line = match line {
+				OutputLine::Out(o) => o,
+				OutputLine::Err(_e) => {
+					// Handle startup errors, but skip repl hello?
+					errors.push(_e);
+					continue;
+				}
+			};
+			if line.contains(REPL_DELIMITER) {
+				debug!("discovered repl delimiter with added colors: {line}");
+				full_delimiter = Some(line.to_owned());
+				break;
+			}
+		}
+		let Some(full_delimiter) = full_delimiter else {
+			for e in errors {
+				error!("{e}");
+			}
+			return Err(Error::SessionInit("failed to discover delimiter"));
+		};
+		let mut res = Self {
+			full_delimiter,
+			nix_handler: ClonableHandler::new(nix_handler),
+			out,
+			stdin,
+			string_wrapping: Default::default(),
+			number_wrapping: Default::default(),
+
+			executing_command: Arc::new(Mutex::new(())),
+
+			next_id: 0,
+			free_list: vec![],
+		};
+		res.train().await?;
+		Ok(res)
+	}
+	async fn train(&mut self) -> Result<()> {
+		{
+			let full_string = self
+				.execute_expression_raw(TRAIN_STRING, &mut NoopHandler)
+				.await?;
+			let string_offset = full_string.find(TRAIN_STRING).expect("contained");
+			let string_prefix = &full_string[..string_offset];
+			let string_suffix = &full_string[string_offset + TRAIN_STRING.len()..];
+			self.string_wrapping = (string_prefix.to_owned(), string_suffix.to_owned());
+		}
+		{
+			let full_number = self
+				.execute_expression_raw(TRAIN_NUMBER, &mut NoopHandler)
+				.await?;
+			let number_offset = full_number.find(TRAIN_NUMBER).expect("contained");
+			let number_prefix = &full_number[..number_offset];
+			let number_suffix = &full_number[number_offset + TRAIN_NUMBER.len()..];
+			self.number_wrapping = (number_prefix.to_owned(), number_suffix.to_owned());
+		}
+		Ok(())
+	}
+	async fn send_command(&mut self, cmd: impl AsRef<[u8]>) -> Result<()> {
+		if tracing::enabled!(Level::DEBUG) && cmd.as_ref() != REPL_DELIMITER.as_bytes() {
+			let cmd_str = String::from_utf8_lossy(cmd.as_ref());
+			tracing::debug!("{cmd_str}");
+		};
+		self.stdin.write_all(cmd.as_ref()).await?;
+		self.stdin.write_all(b"\n").await?;
+		Ok(())
+	}
+	async fn read_until_delimiter(&mut self, err_handler: &mut dyn Handler) -> Result<String> {
+		let mut out = String::new();
+		while let Some(line) = self.out.next().await {
+			let line = match line {
+				OutputLine::Out(out) => out,
+				OutputLine::Err(err) => {
+					err_handler.handle_line(&err);
+					continue;
+				}
+			};
+			if line == self.full_delimiter {
+				return Ok(out);
+			}
+			if !out.is_empty() {
+				out.push('\n');
+			}
+			out.push_str(&line);
+		}
+		return Err(Error::MissingDelimiter);
+	}
+	pub(crate) async fn execute_expression_number(
+		&mut self,
+		expr: impl AsRef<[u8]>,
+	) -> Result<u64> {
+		let num = self.number_wrapping.clone();
+		let n = self.execute_expression_wrapping(expr, &num).await?;
+		n.parse::<u64>().map_err(Error::Int)
+	}
+	async fn execute_expression_string(&mut self, expr: impl AsRef<[u8]>) -> Result<String> {
+		let num = self.string_wrapping.clone();
+		let n = self.execute_expression_wrapping(expr, &num).await?;
+		let str: String = serde_json::from_str(&n)?;
+		Ok(str)
+	}
+	pub(crate) async fn execute_expression_to_json<V: DeserializeOwned>(
+		&mut self,
+		expr: impl AsRef<[u8]>,
+	) -> Result<V> {
+		let mut fexpr = b"builtins.toJSON (".to_vec();
+		fexpr.extend_from_slice(expr.as_ref());
+		fexpr.push(b')');
+		let v = self.execute_expression_string(fexpr).await?;
+		Ok(serde_json::from_str(&v)?)
+	}
+	async fn execute_expression_wrapping(
+		&mut self,
+		expr: impl AsRef<[u8]>,
+		wrapping: &(String, String),
+	) -> Result<String> {
+		let mut nix_handler = self.nix_handler.clone();
+		let mut collected = ErrorCollector::new(&mut nix_handler);
+		let res = self.execute_expression_raw(expr, &mut collected).await?;
+		if res.is_empty() {
+			collected.finish()?;
+			return Err(Error::ExpectedOutput);
+		} else {
+			collected.flush()
+		};
+		let Some(res) = res.strip_prefix(&wrapping.0) else {
+			return Err(Error::InvalidType);
+		};
+		let Some(res) = res.strip_suffix(&wrapping.1) else {
+			return Err(Error::InvalidType);
+		};
+		Ok(res.to_owned())
+	}
+	async fn execute_expression_empty(&mut self, expr: impl AsRef<[u8]>) -> Result<()> {
+		let mut nix_handler = self.nix_handler.clone();
+		let mut collected = ErrorCollector::new(&mut nix_handler);
+		let v = self.execute_expression_raw(expr, &mut collected).await?;
+		collected.finish()?;
+		if !v.is_empty() {
+			return Err(Error::UnexpectedOutput);
+		}
+		Ok(())
+	}
+	pub(crate) async fn execute_expression_raw(
+		&mut self,
+		expr: impl AsRef<[u8]>,
+		err_handler: &mut dyn Handler,
+	) -> Result<String> {
+		// Prevent two commands from being executed in parallel, messing with each other.
+		let _lock = self.executing_command.clone();
+		let _guard = _lock.lock().await;
+
+		self.send_command(expr).await?;
+		// It will be echoed
+		self.send_command(REPL_DELIMITER).await?;
+		self.read_until_delimiter(err_handler).await
+	}
+	pub(crate) async fn execute_assign(&mut self, expr: impl AsRef<str>) -> Result<u32> {
+		let id = self.allocate_id();
+		self.execute_expression_empty(format!("sess_field_{id} = {}", expr.as_ref()))
+			.await?;
+		Ok(id)
+	}
+
+	/// Id should be immediately used
+	fn allocate_id(&mut self) -> u32 {
+		if let Some(free) = self.free_list.pop() {
+			free
+		} else {
+			let v = self.next_id;
+			self.next_id += 1;
+			v
+		}
+	}
+	// Nix has no way to deallocate variable, yet GC will correct everything not reachable.
+	// async fn free_id(&mut self, id: u32) -> Result<()> {
+	// 	self.execute_expression_empty(format!("sess_field_{id} = null"))
+	// 		.await?;
+	// 	self.free_list.push(id);
+	// 	Ok(())
+	// }
+}
addedcrates/nix-eval/src/value.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/nix-eval/src/value.rs
@@ -0,0 +1,291 @@
+use std::{collections::HashMap, fmt, path::PathBuf, sync::Arc};
+
+use better_command::NixHandler;
+use serde::{de::DeserializeOwned, Serialize};
+
+use crate::{macros::NixExprBuilder, nix_go, Error, NixSession, Result};
+
+#[derive(Clone)]
+pub enum Index {
+	Var(String),
+	String(String),
+	#[allow(dead_code)]
+	Apply(String),
+	#[allow(dead_code)]
+	Expr(NixExprBuilder),
+	ExprApply(NixExprBuilder),
+	Pipe(NixExprBuilder),
+}
+impl Index {
+	pub fn var(v: impl AsRef<str>) -> Self {
+		let v = v.as_ref();
+		assert!(
+			!(v.contains('.') | v.contains(' ')),
+			"bad variable name: {v}"
+		);
+		Self::Var(v.to_owned())
+	}
+	pub fn attr(v: impl AsRef<str>) -> Self {
+		Self::String(v.as_ref().to_owned())
+	}
+	#[allow(dead_code)]
+	pub fn apply(v: impl Serialize) -> Self {
+		let serialized = nixlike::serialize(v).expect("invalid value for apply");
+		Self::Apply(serialized.trim_end().to_owned())
+	}
+}
+impl fmt::Display for Index {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		match self {
+			Index::Var(v) => {
+				write!(f, "{v}")
+			}
+			Index::String(k) => {
+				let v = nixlike::format_identifier(k.as_str());
+				write!(f, ".{v}")
+			}
+			Index::Apply(o) => {
+				write!(f, "<apply>({o})")
+			}
+			Index::Expr(e) => {
+				write!(f, "[{}]", e.out)
+			}
+			Index::ExprApply(e) => {
+				write!(f, "<apply>({})", e.out)
+			}
+			Index::Pipe(e) => {
+				write!(f, "<map>({})", e.out)
+			}
+		}
+	}
+}
+impl fmt::Debug for Index {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "{self}")
+	}
+}
+struct PathDisplay<'i>(&'i [Index]);
+impl fmt::Display for PathDisplay<'_> {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		for i in self.0 {
+			write!(f, "{i}")?;
+		}
+		Ok(())
+	}
+}
+struct ValueInner {
+	full_path: Option<Vec<Index>>,
+	session: NixSession,
+	value: Option<u32>,
+}
+#[derive(Clone)]
+pub struct Value(Arc<ValueInner>);
+impl Value {
+	fn root(session: NixSession) -> Self {
+		Self(Arc::new(ValueInner {
+			full_path: Some(vec![]),
+			session,
+			value: None,
+		}))
+	}
+	async fn new(session: NixSession, query: &str) -> Result<Self> {
+		let vid = session.0.lock().await.execute_assign(query).await?;
+		Ok(Self(Arc::new(ValueInner {
+			full_path: None,
+			session,
+			value: Some(vid),
+		})))
+	}
+	/// Get a top-level binding.
+	///
+	/// In flake repl session, every output is exposed as top-level binding.
+	pub async fn binding(session: NixSession, field: &str) -> Result<Self> {
+		Self::root(session).select([Index::var(field)]).await
+	}
+	pub async fn select<'a>(&self, name: impl IntoIterator<Item = Index>) -> Result<Self> {
+		let mut used_fields = Vec::new();
+		let mut name = name.into_iter();
+
+		let mut full_path = self.0.full_path.clone();
+		let mut query = if let Some(id) = self.0.value {
+			format!("sess_field_{id}")
+		} else {
+			let first = name.next();
+			if let Some(Index::Var(i)) = first {
+				if let Some(full_path) = &mut full_path {
+					full_path.push(Index::Var(i.clone()));
+				}
+				i.clone()
+			} else {
+				panic!("first path item should be variable, got {first:?}")
+			}
+		};
+		for v in name {
+			if let Some(full_path) = &mut full_path {
+				full_path.push(v.clone());
+			}
+			match v {
+				Index::Var(_) => panic!("var item may only be first"),
+				Index::String(s) => {
+					let escaped =
+						nixlike::serialize(s).expect("strings are always serialized successfully");
+					query.push('.');
+					query.push_str(escaped.trim());
+				}
+				Index::Apply(a) => {
+					// In cases like `a {}.b` first `{}.b` will be evaluated, so `a {}` should be encased in `()`
+					query = format!("({query} {a})");
+				}
+				Index::Expr(e) => {
+					let index = Value::new(self.0.session.clone(), &e.out).await?;
+					used_fields.push(index.clone());
+					query.push('.');
+					let index = format!("${{sess_field_{}}}", index.0.value.expect("value"));
+					query.push_str(&index);
+				}
+				Index::ExprApply(e) => {
+					let index = Value::new(self.0.session.clone(), &e.out).await?;
+					used_fields.push(index.clone());
+					query.push(' ');
+					let index = format!("sess_field_{}", index.0.value.expect("value"));
+					query.push_str(&index);
+					query = format!("({query})");
+				}
+				Index::Pipe(v) => {
+					let index = Value::new(self.0.session.clone(), &v.out).await?;
+					used_fields.push(index.clone());
+					let index = format!("sess_field_{}", index.0.value.expect("value"));
+					query = format!("({index} {query})");
+				}
+			}
+		}
+
+		let vid = self
+			.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_assign(&query)
+			.await
+			.map_err(|e| e.context(self.attribute()))?;
+		Ok(Self(Arc::new(ValueInner {
+			full_path,
+			session: self.0.session.clone(),
+			value: Some(vid),
+		})))
+	}
+	pub async fn as_json<V: DeserializeOwned>(&self) -> Result<V> {
+		let id = self.0.value.expect("can't serialize root field");
+		let query = format!("sess_field_{id}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.map_err(|e| e.context(self.attribute()))
+	}
+	#[allow(dead_code)]
+	pub async fn has_field(&self, name: &str) -> Result<bool> {
+		let id = self.0.value.expect("can't list root fields");
+		let key = nixlike::escape_string(name);
+		let query = format!("sess_field_{id} ? {key}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.map_err(|e| e.context(self.attribute()))
+	}
+	pub async fn list_fields(&self) -> Result<Vec<String>> {
+		let id = self.0.value.expect("can't list root fields");
+		let query = format!("builtins.attrNames sess_field_{id}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.map_err(|e| e.context(self.attribute()))
+	}
+	pub async fn type_of(&self) -> Result<String> {
+		let id = self.0.value.expect("can't list root fields");
+		let query = format!("builtins.typeOf sess_field_{id}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.map_err(|e| e.context(self.attribute()))
+	}
+	#[allow(dead_code)]
+	pub async fn import(&self) -> Result<Self> {
+		let import = Self::new(self.0.session.clone(), "import").await?;
+		Ok(nix_go!(self | import))
+	}
+	pub async fn build(&self) -> Result<HashMap<String, PathBuf>> {
+		let id = self.0.value.expect("can't use build on not-value");
+		let query = format!(":b sess_field_{id}");
+		let vid = self
+			.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_raw(&query, &mut NixHandler::default())
+			.await?;
+		if vid.is_empty() {
+			return Err(Error::BuildFailed {
+				attribute: self.attribute(),
+				error: "build produced no output".to_owned(),
+			});
+		}
+		let Some(vid) = vid.strip_prefix("This derivation produced the following outputs:\n")
+		else {
+			return Err(Error::BuildFailed {
+				attribute: self.attribute(),
+				error: format!("failed to parse output: {vid}"),
+			});
+		};
+		let outputs = vid
+			.split('\n')
+			.filter(|v| !v.is_empty())
+			.map(|v| v.split_once(" -> ").expect("unexpected build output"))
+			.map(|(a, b)| (a.trim_start().to_owned(), PathBuf::from(b)))
+			.collect();
+		Ok(outputs)
+	}
+
+	fn attribute(&self) -> String {
+		if let Some(full_path) = &self.0.full_path {
+			PathDisplay(full_path).to_string()
+		} else {
+			"<root>".to_owned()
+		}
+	}
+
+	pub(crate) fn session(&self) -> NixSession {
+		self.0.session.clone()
+	}
+
+	pub(crate) fn session_field_id(&self) -> u32 {
+		self.0.value.expect("not root")
+	}
+}
+impl Drop for ValueInner {
+	fn drop(&mut self) {
+		if let Some(id) = self.value {
+			if let Ok(mut lock) = self.session.0.try_lock() {
+				lock.free_list.push(id)
+			}
+			// Leaked
+		}
+	}
+}
modifiedcrates/nixlike/src/lib.rsdiffbeforeafterboth
--- a/crates/nixlike/src/lib.rs
+++ b/crates/nixlike/src/lib.rs
@@ -1,5 +1,9 @@
 //! Serialization/deserialization for nix subset usable for static configurations
-//! Serialized results from this library are readable by both this library and standard nix tools
+//!
+//! Serialized results from this library are readable by both this library and standard nix tools.
+//! Nix produced output should also be readable by this library, however, you can't write arbitrary nix
+//! expressions and expect it to work, only basic primitives are supported, and there is no
+//! variables/recursive records, interpolation, e.t.c.
 
 use linked_hash_map::LinkedHashMap;
 use peg::str::LineCol;
@@ -198,9 +202,15 @@
 
 #[test]
 fn parse_multiline() {
+	// First line is ignored, unless there is a significant characters.
 	assert_eq!(nixlike::multiline_string("''\n''").expect("parse"), "");
+	// Rest of the lines are processed normally.
 	assert_eq!(nixlike::multiline_string("''\n\n''").expect("parse"), "\n");
+	// Example with significant character on first line.
 	assert_eq!(nixlike::multiline_string("''t\n''").expect("parse"), "t\n");
+	// There might be nothing in multiline string block.
 	assert_eq!(nixlike::multiline_string("''''").expect("parse"), "");
+	// And there also might just be spaces, they are removed due to dedent, and output is empty because
+	// first line was also ignored due to missing significant characters.
 	assert_eq!(nixlike::multiline_string("''    ''").expect("parse"), "");
 }
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -7,11 +7,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1715274763,
-        "narHash": "sha256-3Iv1PGHJn9sV3HO4FlOVaaztOxa9uGLfOmUWrH7v7+A=",
+        "lastModified": 1716569590,
+        "narHash": "sha256-5eDbq8TuXFGGO3mqJFzhUbt5zHVTf5zilQoyW5jnJwo=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "27025ab71bdca30e7ed0a16c88fd74c5970fc7f5",
+        "rev": "109987da061a1bf452f435f1653c47511587d919",
         "type": "github"
       },
       "original": {
@@ -40,11 +40,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1715619775,
-        "narHash": "sha256-c1XVqTH9IeUukc4LcWLzHCSpMfo4Dj4K8t/kLV3c80c=",
+        "lastModified": 1716658583,
+        "narHash": "sha256-A93mYmlLvCz0YjQiQ5Tc3DpLrP6Brs+gAlK9nlnSOVg=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "0cb78770f66945bb3130f762aef05373e283f2b9",
+        "rev": "3e280884c0b0e8222ec6b05a99db01505964e1c3",
         "type": "github"
       },
       "original": {
@@ -54,11 +54,28 @@
         "type": "github"
       }
     },
+    "nixpkgs-stable-for-tests": {
+      "locked": {
+        "lastModified": 1716361217,
+        "narHash": "sha256-mzZDr00WUiUXVm1ujBVv6A0qRd8okaITyUp4ezYRgc4=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "46397778ef1f73414b03ed553a3368f0e7e33c2f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-23.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
         "crane": "crane",
         "flake-utils": "flake-utils",
         "nixpkgs": "nixpkgs",
+        "nixpkgs-stable-for-tests": "nixpkgs-stable-for-tests",
         "rust-overlay": "rust-overlay"
       }
     },
@@ -72,11 +89,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1715566659,
-        "narHash": "sha256-OpI0TnN+uE0vvxjPStlTzf5RTohIXVSMwrP9NEgMtaY=",
+        "lastModified": 1716603336,
+        "narHash": "sha256-81u/zd7V+XRTq88zwRLxw5GnwZyEiAvGA2BvAXUe864=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "6c465248316cd31502c82f81f1a3acf2d621b01c",
+        "rev": "4d0f1e4d5d65c23cdbb77e4b0d91940be7309bd4",
         "type": "github"
       },
       "original": {
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -3,6 +3,7 @@
 
   inputs = {
     nixpkgs.url = "github:nixos/nixpkgs/master";
+    nixpkgs-stable-for-tests.url = "github:nixos/nixpkgs/nixos-23.11";
     rust-overlay = {
       url = "github:oxalica/rust-overlay";
       inputs = {
@@ -21,6 +22,7 @@
     rust-overlay,
     flake-utils,
     nixpkgs,
+    nixpkgs-stable-for-tests,
     crane,
   }:
     with nixpkgs.lib;
@@ -37,11 +39,37 @@
         rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
         craneLib = (crane.mkLib pkgs).overrideToolchain rust;
       in {
-        packages = import ./pkgs {
-          inherit (pkgs) callPackage;
-          inherit craneLib;
-        };
-        devShell = craneLib.devShell {
+        packages = let
+          packages = import ./pkgs {
+            inherit (pkgs) callPackage;
+            inherit craneLib;
+          };
+        in
+          packages // {default = packages.fleet;};
+
+        checks = let
+          packages = import ./pkgs {
+            inherit (pkgs) callPackage;
+            craneLib = crane.mkLib (import nixpkgs {inherit system;});
+          };
+          packages-with-nixpkgs-stable = import ./pkgs {
+            inherit (pkgs) callPackage;
+            craneLib = crane.mkLib (import nixpkgs-stable-for-tests {inherit system;});
+          };
+          prefixAttrs = prefix: attrs:
+            nixpkgs.lib.attrsets.mapAttrs' (name: value: {
+              name = "${prefix}${name}";
+              value = value.overrideAttrs (prev: {
+                pname = "${prefix}${prev.pname}";
+              });
+            })
+            attrs;
+        in
+          # `fleet` crate wants nightly rust, also little sense of supporting it on stable nixpkgs.
+          (prefixAttrs "nixpkgs-" (removeAttrs packages ["fleet"]))
+          // (prefixAttrs "nixpkgs-stable-" (removeAttrs packages-with-nixpkgs-stable ["fleet"]));
+
+        devShells.default = craneLib.devShell {
           nativeBuildInputs = with pkgs; [
             alejandra
             lld
deletednixos/fleetPkgs.nixdiffbeforeafterboth
--- a/nixos/fleetPkgs.nix
+++ /dev/null
@@ -1,24 +0,0 @@
-{...}: {
-  nixpkgs.overlays = [
-    # Not using craneLib here, because we don't want to have two different rust versions for some platforms.
-    (final: prev: {
-      fleet-install-secrets = prev.callPackage ({rustPlatform}:
-        rustPlatform.buildRustPackage rec {
-          pname = "fleet-install-secrets";
-          name = "${pname}";
-
-          src = ../.;
-          strictDeps = true;
-
-          buildAndTestSubdir = "cmds/install-secrets";
-
-          cargoLock = {
-            lockFile = ../Cargo.lock;
-            outputHashes = {
-              "alejandra-3.0.0" = "sha256-q2oTMen8E1YUbNyU4chPOj728/YR0RzdpN+bNjZX2QU=";
-            };
-          };
-        }) {};
-    })
-  ];
-}
modifiedpkgs/default.nixdiffbeforeafterboth
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -1,9 +1,7 @@
 {
   callPackage,
   craneLib,
-}: rec {
-  default = fleet;
-
+}: {
   fleet-install-secrets = callPackage ./fleet-install-secrets.nix {inherit craneLib;};
   fleet = callPackage ./fleet.nix {inherit craneLib;};
 }
addedpkgs/generator-helper.nixdiffbeforeafterboth
--- /dev/null
+++ b/pkgs/generator-helper.nix
@@ -0,0 +1,14 @@
+
+{craneLib}:
+craneLib.buildPackage rec {
+  pname = "fleet-generator-helper";
+
+  src = craneLib.cleanCargoSource (craneLib.path ../.);
+  strictDeps = true;
+
+  cargoExtraArgs = "--locked -p ${pname}";
+
+  postInstall = ''
+		mv bin/${pname} bin/genhelper
+  '';
+}
modifiedrust-toolchain.tomldiffbeforeafterboth
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,3 @@
 [toolchain]
-channel = "nightly-2024-02-10"
+channel = "nightly-2024-05-01"
 components = ["rustfmt", "clippy", "rust-analyzer", "rust-src"]