Skip to main content

nex_pkg/
exec.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{bail, Context, Result};
5
6pub fn nix_experimental_args() -> [&'static str; 2] {
7    ["--extra-experimental-features", "nix-command flakes"]
8}
9
10fn rebuild_experimental_args(platform: crate::discover::Platform) -> &'static [&'static str] {
11    match platform {
12        crate::discover::Platform::Darwin => {
13            &["--option", "experimental-features", "nix-command flakes"]
14        }
15        crate::discover::Platform::Linux => {
16            &["--extra-experimental-features", "nix-command flakes"]
17        }
18    }
19}
20
21pub fn nix_command() -> Command {
22    let mut command = Command::new(find_nix());
23    command.args(nix_experimental_args());
24    command
25}
26
27/// Resolve the path to the `nix` binary.
28/// Checks well-known locations first, then falls back to PATH-based lookup.
29/// Preferring hardcoded paths prevents PATH-based binary injection.
30pub fn find_nix() -> String {
31    let candidates = [
32        "/nix/var/nix/profiles/default/bin/nix",
33        "/run/current-system/sw/bin/nix",
34        "/etc/profiles/per-user/default/bin/nix",
35    ];
36    for path in &candidates {
37        if Path::new(path).exists() {
38            tracing::debug!(path, "resolved nix binary");
39            return path.to_string();
40        }
41    }
42
43    // Fall back to PATH-based lookup if no well-known path exists
44    if let Ok(output) = Command::new("nix").arg("--version").output() {
45        if output.status.success() {
46            tracing::debug!(path = "nix", "resolved nix binary");
47            return "nix".to_string();
48        }
49    }
50
51    // Fall back to bare name — will produce a clear "not found" error
52    tracing::debug!(path = "nix", "resolved nix binary");
53    "nix".to_string()
54}
55
56/// Convert captured stdout/stderr into integration-safe text.
57pub fn captured_text(bytes: &[u8]) -> String {
58    crate::ansi::sanitize_terminal_capture(&String::from_utf8_lossy(bytes))
59}
60
61/// Run a command, inheriting stdout/stderr. Returns Ok if exit code 0.
62fn run(cmd: &mut Command) -> Result<()> {
63    let program = cmd.get_program().to_string_lossy().to_string();
64    tracing::debug!(program = %program, "running command");
65    match cmd.status() {
66        Ok(status) if status.success() => Ok(()),
67        Ok(status) => {
68            tracing::error!(program = %program, code = ?status.code(), "command failed");
69            bail!(
70                "{program} failed with exit code {}",
71                status.code().unwrap_or(-1)
72            )
73        }
74        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
75            bail!(
76                "{program} not found — is it installed and in your PATH?\n\
77                   hint: if you haven't run darwin-rebuild yet, see: nex.styrene.io"
78            )
79        }
80        Err(e) => Err(e).with_context(|| format!("failed to run {program}")),
81    }
82}
83
84/// Stage all changes and commit in a git repo. Warns on failure instead of
85/// returning an error, since git may not be configured in all environments.
86pub fn git_commit(repo: &Path, message: &str) {
87    tracing::debug!(repo = %repo.display(), %message, "git commit");
88    let add = Command::new("git")
89        .args(["add", "-A"])
90        .current_dir(repo)
91        .output();
92
93    match add {
94        Ok(o) if o.status.success() => {}
95        Ok(o) => {
96            tracing::warn!(
97                exit_code = o.status.code().unwrap_or(-1),
98                stderr = %captured_text(&o.stderr).trim(),
99                "git add failed"
100            );
101            return;
102        }
103        Err(e) => {
104            tracing::warn!(error = %e, "could not run git add");
105            return;
106        }
107    }
108
109    let commit = Command::new("git")
110        .args(["commit", "-m", message])
111        .current_dir(repo)
112        .output();
113
114    match commit {
115        Ok(o) if o.status.success() => {}
116        Ok(o) => {
117            let stderr = captured_text(&o.stderr);
118            // "nothing to commit" is not a real failure
119            if !stderr.contains("nothing to commit") {
120                tracing::warn!(
121                    stderr = %stderr.trim(),
122                    "git commit failed — please commit manually"
123                );
124            }
125        }
126        Err(e) => {
127            tracing::warn!(error = %e, "could not run git commit");
128        }
129    }
130}
131
132/// Validate that a nix package attribute exists in nixpkgs.
133pub fn nix_eval_exists(pkg: &str) -> Result<bool> {
134    tracing::debug!(%pkg, "checking nixpkgs existence");
135    let output = nix_command()
136        .args(["eval", &format!("nixpkgs#{pkg}.name"), "--raw"])
137        .stderr(std::process::Stdio::null())
138        .output()
139        .context("failed to run nix eval")?;
140    Ok(output.status.success())
141}
142
143/// Get the version of a nix package from nixpkgs. Returns None if not found.
144pub fn nix_eval_version(pkg: &str) -> Result<Option<String>> {
145    tracing::debug!(%pkg, "querying nixpkgs version");
146    let output = nix_command()
147        .args(["eval", &format!("nixpkgs#{pkg}.version"), "--raw"])
148        .stderr(std::process::Stdio::null())
149        .output()
150        .context("failed to run nix eval")?;
151    if output.status.success() {
152        let version = captured_text(&output.stdout).trim().to_string();
153        if version.is_empty() {
154            Ok(None)
155        } else {
156            Ok(Some(version))
157        }
158    } else {
159        Ok(None)
160    }
161}
162
163/// Check whether the `brew` binary is available.
164pub fn brew_available() -> bool {
165    Command::new("brew")
166        .arg("--version")
167        .stdout(std::process::Stdio::null())
168        .stderr(std::process::Stdio::null())
169        .status()
170        .map(|s| s.success())
171        .unwrap_or(false)
172}
173
174/// Check if a brew cask exists and return its version.
175pub fn brew_cask_info(pkg: &str) -> Result<Option<String>> {
176    let output = Command::new("brew")
177        .args(["info", "--json=v2", "--cask", pkg])
178        .stderr(std::process::Stdio::null())
179        .output();
180
181    let output = match output {
182        Ok(o) => o,
183        Err(_) => return Ok(None), // brew not in PATH
184    };
185
186    if !output.status.success() {
187        return Ok(None);
188    }
189
190    let json: serde_json::Value =
191        serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Null);
192
193    let version = json
194        .get("casks")
195        .and_then(|c| c.get(0))
196        .and_then(|c| c.get("version"))
197        .and_then(|v| v.as_str())
198        .map(String::from);
199
200    Ok(version)
201}
202
203/// Check if a brew formula exists and return its version.
204pub fn brew_formula_info(pkg: &str) -> Result<Option<String>> {
205    let output = Command::new("brew")
206        .args(["info", "--json=v2", "--formula", pkg])
207        .stderr(std::process::Stdio::null())
208        .output();
209
210    let output = match output {
211        Ok(o) => o,
212        Err(_) => return Ok(None),
213    };
214
215    if !output.status.success() {
216        return Ok(None);
217    }
218
219    let json: serde_json::Value =
220        serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Null);
221
222    let version = json
223        .get("formulae")
224        .and_then(|f| f.get(0))
225        .and_then(|f| f.get("versions"))
226        .and_then(|v| v.get("stable"))
227        .and_then(|v| v.as_str())
228        .map(String::from);
229
230    Ok(version)
231}
232
233/// List installed brew formulae (top-level only, not deps).
234pub fn brew_leaves() -> Result<Vec<String>> {
235    let output = Command::new("brew")
236        .arg("leaves")
237        .stderr(std::process::Stdio::null())
238        .output();
239
240    let output = match output {
241        Ok(o) if o.status.success() => o,
242        _ => return Ok(Vec::new()),
243    };
244
245    Ok(captured_text(&output.stdout)
246        .lines()
247        .filter(|l| !l.is_empty())
248        .map(String::from)
249        .collect())
250}
251
252/// List installed brew casks.
253pub fn brew_list_casks() -> Result<Vec<String>> {
254    let output = Command::new("brew")
255        .args(["list", "--cask"])
256        .stderr(std::process::Stdio::null())
257        .output();
258
259    let output = match output {
260        Ok(o) if o.status.success() => o,
261        _ => return Ok(Vec::new()),
262    };
263
264    Ok(captured_text(&output.stdout)
265        .lines()
266        .filter(|l| !l.is_empty())
267        .map(String::from)
268        .collect())
269}
270
271/// Search nixpkgs for packages matching a query.
272pub fn nix_search(query: &str) -> Result<()> {
273    run(nix_command().args(["search", "nixpkgs", query]))
274}
275
276fn test_mode() -> bool {
277    std::env::var_os("NEX_TESTING").is_some()
278}
279
280/// Resolve the absolute path to darwin-rebuild so sudo can find it.
281/// Checks well-known locations first to prevent PATH-based binary injection.
282fn find_darwin_rebuild() -> Result<String> {
283    if test_mode() {
284        return Ok("darwin-rebuild".to_string());
285    }
286
287    // Known locations after nix-darwin activation
288    let candidates = [
289        "/run/current-system/sw/bin/darwin-rebuild",
290        "/nix/var/nix/profiles/system/sw/bin/darwin-rebuild",
291    ];
292    for path in &candidates {
293        if std::path::Path::new(path).exists() {
294            return Ok(path.to_string());
295        }
296    }
297
298    // Fall back to PATH-based lookup if no well-known path exists
299    if let Ok(output) = Command::new("darwin-rebuild").arg("--help").output() {
300        if output.status.success() {
301            return Ok("darwin-rebuild".to_string());
302        }
303    }
304
305    bail!(
306        "darwin-rebuild not found — is nix-darwin activated?\n\
307         hint: run `nex init` first, or see nex.styrene.io"
308    )
309}
310
311/// Resolve the absolute path to nixos-rebuild.
312/// Checks well-known locations first to prevent PATH-based binary injection.
313fn find_nixos_rebuild() -> Result<String> {
314    if test_mode() {
315        return Ok("nixos-rebuild".to_string());
316    }
317
318    let candidates = [
319        "/run/current-system/sw/bin/nixos-rebuild",
320        "/nix/var/nix/profiles/system/sw/bin/nixos-rebuild",
321    ];
322    for path in &candidates {
323        if std::path::Path::new(path).exists() {
324            return Ok(path.to_string());
325        }
326    }
327
328    // Fall back to PATH-based lookup if no well-known path exists
329    if let Ok(output) = Command::new("nixos-rebuild").arg("--help").output() {
330        if output.status.success() {
331            return Ok("nixos-rebuild".to_string());
332        }
333    }
334
335    bail!(
336        "nixos-rebuild not found — is NixOS installed?\n\
337         hint: run `nex init` first, or see nex.styrene.io"
338    )
339}
340
341/// Run darwin-rebuild switch (requires sudo for system activation).
342/// After a successful switch, re-registers nix .app bundles with LaunchServices
343/// so Spotlight displays correct icons.
344pub fn darwin_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
345    tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
346    ensure_profile_dirs();
347    let dr = find_darwin_rebuild()?;
348    let flake = format!(".#{hostname}");
349    run(Command::new("sudo")
350        .arg(&dr)
351        .arg("switch")
352        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
353        .args(["--flake", &flake])
354        .current_dir(repo))?;
355    refresh_app_icons();
356    Ok(())
357}
358
359/// Run nixos-rebuild switch (requires sudo for system activation).
360pub fn nixos_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
361    tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
362    ensure_profile_dirs();
363    let nr = find_nixos_rebuild()?;
364    let flake = format!(".#{hostname}");
365    run(Command::new("sudo")
366        .arg(&nr)
367        .arg("switch")
368        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
369        .args(["--flake", &flake])
370        .current_dir(repo))
371}
372
373/// Platform-aware system rebuild switch. Dispatches to darwin-rebuild or nixos-rebuild.
374pub fn system_rebuild_switch(
375    repo: &Path,
376    hostname: &str,
377    platform: crate::discover::Platform,
378) -> Result<()> {
379    match platform {
380        crate::discover::Platform::Darwin => darwin_rebuild_switch(repo, hostname),
381        crate::discover::Platform::Linux => nixos_rebuild_switch(repo, hostname),
382    }
383}
384
385/// Ensure home-manager profile directories exist.
386/// Determinate Nix on fresh macOS doesn't create per-user profile dirs,
387/// which causes home-manager to fail with "Could not find suitable profile directory".
388/// Also fixes ~/.local ownership if the install script created it as root.
389pub fn ensure_profile_dirs() {
390    tracing::debug!("ensuring profile directories");
391    let user = std::env::var("USER")
392        .or_else(|_| std::env::var("LOGNAME"))
393        .unwrap_or_default();
394    if user.is_empty() {
395        eprintln!("  warning: USER not set, skipping profile directory setup");
396        return;
397    }
398
399    // Fix ~/.local ownership — the install script may have created it via sudo
400    if let Some(home) = dirs::home_dir() {
401        let dot_local = home.join(".local");
402        if dot_local.exists() {
403            if let Ok(meta) = std::fs::metadata(&dot_local) {
404                use std::os::unix::fs::MetadataExt;
405                if meta.uid() == 0 {
406                    if let Ok(s) = Command::new("sudo")
407                        .args(["chown", "-R", &user, &dot_local.to_string_lossy()])
408                        .status()
409                    {
410                        if !s.success() {
411                            eprintln!(
412                                "  warning: failed to fix ownership of {}",
413                                dot_local.display()
414                            );
415                        }
416                    }
417                }
418            }
419        }
420    }
421
422    let dirs = [
423        format!("/nix/var/nix/profiles/per-user/{user}"),
424        dirs::home_dir()
425            .map(|h| {
426                h.join(".local/state/nix/profiles")
427                    .to_string_lossy()
428                    .into_owned()
429            })
430            .unwrap_or_default(),
431    ];
432
433    for dir in &dirs {
434        if dir.is_empty() {
435            continue;
436        }
437        let path = Path::new(dir);
438        if path.exists() {
439            continue;
440        }
441        if dir.starts_with("/nix/") {
442            let ok = Command::new("sudo")
443                .args(["mkdir", "-p", dir])
444                .status()
445                .map(|s| s.success())
446                .unwrap_or(false)
447                && Command::new("sudo")
448                    .args(["chown", &user, dir])
449                    .status()
450                    .map(|s| s.success())
451                    .unwrap_or(false);
452            if !ok {
453                eprintln!("  warning: failed to create {dir}");
454            }
455        } else if std::fs::create_dir_all(path).is_err() {
456            eprintln!("  warning: failed to create {dir}");
457        }
458    }
459}
460
461/// Re-register nix .app bundles with LaunchServices so Spotlight shows correct icons.
462fn refresh_app_icons() {
463    let lsregister = "/System/Library/Frameworks/CoreServices.framework/\
464                      Frameworks/LaunchServices.framework/Support/lsregister";
465
466    if !Path::new(lsregister).exists() {
467        return;
468    }
469
470    let app_dirs: Vec<std::path::PathBuf> = [
471        dirs::home_dir().map(|h| h.join("Applications/Home Manager Apps")),
472        Some(std::path::PathBuf::from("/Applications/Nix Apps")),
473    ]
474    .into_iter()
475    .flatten()
476    .filter(|d| d.exists())
477    .collect();
478
479    if app_dirs.is_empty() {
480        return;
481    }
482
483    for dir in &app_dirs {
484        let entries = match std::fs::read_dir(dir) {
485            Ok(e) => e,
486            Err(_) => continue,
487        };
488        for entry in entries.flatten() {
489            let path = entry.path();
490            if path.extension().and_then(|e| e.to_str()) == Some("app") {
491                let _ = Command::new(lsregister).args(["-f"]).arg(&path).output();
492            }
493        }
494    }
495}
496
497/// Run darwin-rebuild build (for diff). No sudo needed — build only.
498pub fn darwin_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
499    let dr = find_darwin_rebuild()?;
500    let flake = format!(".#{hostname}");
501    run(Command::new(&dr)
502        .arg("build")
503        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
504        .args(["--flake", &flake])
505        .current_dir(repo))
506}
507
508/// Run nixos-rebuild build (for diff). No sudo needed — build only.
509pub fn nixos_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
510    let nr = find_nixos_rebuild()?;
511    let flake = format!(".#{hostname}");
512    run(Command::new(&nr)
513        .arg("build")
514        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
515        .args(["--flake", &flake])
516        .current_dir(repo))
517}
518
519/// Platform-aware system rebuild build.
520pub fn system_rebuild_build(
521    repo: &Path,
522    hostname: &str,
523    platform: crate::discover::Platform,
524) -> Result<()> {
525    match platform {
526        crate::discover::Platform::Darwin => darwin_rebuild_build(repo, hostname),
527        crate::discover::Platform::Linux => nixos_rebuild_build(repo, hostname),
528    }
529}
530
531/// Run darwin-rebuild --rollback (requires sudo for system activation).
532pub fn darwin_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
533    let dr = find_darwin_rebuild()?;
534    let flake = format!(".#{hostname}");
535    run(Command::new("sudo")
536        .arg(&dr)
537        .arg("switch")
538        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
539        .args(["--rollback", "--flake", &flake])
540        .current_dir(repo))
541}
542
543/// Run nixos-rebuild --rollback (requires sudo for system activation).
544pub fn nixos_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
545    let nr = find_nixos_rebuild()?;
546    let flake = format!(".#{hostname}");
547    run(Command::new("sudo")
548        .arg(&nr)
549        .arg("switch")
550        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
551        .args(["--rollback", "--flake", &flake])
552        .current_dir(repo))
553}
554
555/// Platform-aware system rebuild rollback.
556pub fn system_rebuild_rollback(
557    repo: &Path,
558    hostname: &str,
559    platform: crate::discover::Platform,
560) -> Result<()> {
561    match platform {
562        crate::discover::Platform::Darwin => darwin_rebuild_rollback(repo, hostname),
563        crate::discover::Platform::Linux => nixos_rebuild_rollback(repo, hostname),
564    }
565}
566
567/// Run nix flake update.
568pub fn nix_flake_update(repo: &Path) -> Result<()> {
569    run(nix_command().args(["flake", "update"]).current_dir(repo))
570}
571
572/// Run nix shell for an ephemeral package.
573pub fn nix_shell(pkg: &str) -> Result<()> {
574    run(nix_command().args(["shell", &format!("nixpkgs#{pkg}")]))
575}
576
577/// Show diff between current system and new build.
578pub fn nix_diff_closures(repo: &Path) -> Result<()> {
579    run(nix_command()
580        .args([
581            "store",
582            "diff-closures",
583            "/nix/var/nix/profiles/system",
584            "./result",
585        ])
586        .current_dir(repo))
587}
588
589/// Garbage collect nix store and remove old generations.
590pub fn nix_gc() -> Result<()> {
591    let nix = find_nix();
592    run(nix_command().args(["store", "gc"]))?;
593    // Remove old generations — nix-collect-garbage -d, using the same bin dir as nix
594    let nix_path = std::path::PathBuf::from(&nix);
595    if let Some(bin_dir) = nix_path.parent() {
596        let gc_bin = bin_dir.join("nix-collect-garbage");
597        if gc_bin.exists() {
598            run(Command::new(&gc_bin).args(["-d"]))?;
599        }
600    }
601    Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606    use super::rebuild_experimental_args;
607
608    #[test]
609    fn darwin_rebuild_uses_supported_experimental_feature_option() {
610        assert_eq!(
611            rebuild_experimental_args(crate::discover::Platform::Darwin),
612            ["--option", "experimental-features", "nix-command flakes"]
613        );
614    }
615
616    #[test]
617    fn nixos_rebuild_keeps_nixos_rebuild_experimental_feature_flag() {
618        assert_eq!(
619            rebuild_experimental_args(crate::discover::Platform::Linux),
620            ["--extra-experimental-features", "nix-command flakes"]
621        );
622    }
623}