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
276/// Resolve the absolute path to darwin-rebuild so sudo can find it.
277/// Checks well-known locations first to prevent PATH-based binary injection.
278fn find_darwin_rebuild() -> Result<String> {
279    // Known locations after nix-darwin activation
280    let candidates = [
281        "/run/current-system/sw/bin/darwin-rebuild",
282        "/nix/var/nix/profiles/system/sw/bin/darwin-rebuild",
283    ];
284    for path in &candidates {
285        if std::path::Path::new(path).exists() {
286            return Ok(path.to_string());
287        }
288    }
289
290    // Fall back to PATH-based lookup if no well-known path exists
291    if let Ok(output) = Command::new("darwin-rebuild").arg("--help").output() {
292        if output.status.success() {
293            return Ok("darwin-rebuild".to_string());
294        }
295    }
296
297    bail!(
298        "darwin-rebuild not found — is nix-darwin activated?\n\
299         hint: run `nex init` first, or see nex.styrene.io"
300    )
301}
302
303/// Resolve the absolute path to nixos-rebuild.
304/// Checks well-known locations first to prevent PATH-based binary injection.
305fn find_nixos_rebuild() -> Result<String> {
306    let candidates = [
307        "/run/current-system/sw/bin/nixos-rebuild",
308        "/nix/var/nix/profiles/system/sw/bin/nixos-rebuild",
309    ];
310    for path in &candidates {
311        if std::path::Path::new(path).exists() {
312            return Ok(path.to_string());
313        }
314    }
315
316    // Fall back to PATH-based lookup if no well-known path exists
317    if let Ok(output) = Command::new("nixos-rebuild").arg("--help").output() {
318        if output.status.success() {
319            return Ok("nixos-rebuild".to_string());
320        }
321    }
322
323    bail!(
324        "nixos-rebuild not found — is NixOS installed?\n\
325         hint: run `nex init` first, or see nex.styrene.io"
326    )
327}
328
329/// Run darwin-rebuild switch (requires sudo for system activation).
330/// After a successful switch, re-registers nix .app bundles with LaunchServices
331/// so Spotlight displays correct icons.
332pub fn darwin_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
333    tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
334    ensure_profile_dirs();
335    let dr = find_darwin_rebuild()?;
336    let flake = format!(".#{hostname}");
337    run(Command::new("sudo")
338        .arg(&dr)
339        .arg("switch")
340        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
341        .args(["--flake", &flake])
342        .current_dir(repo))?;
343    refresh_app_icons();
344    Ok(())
345}
346
347/// Run nixos-rebuild switch (requires sudo for system activation).
348pub fn nixos_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
349    tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
350    ensure_profile_dirs();
351    let nr = find_nixos_rebuild()?;
352    let flake = format!(".#{hostname}");
353    run(Command::new("sudo")
354        .arg(&nr)
355        .arg("switch")
356        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
357        .args(["--flake", &flake])
358        .current_dir(repo))
359}
360
361/// Platform-aware system rebuild switch. Dispatches to darwin-rebuild or nixos-rebuild.
362pub fn system_rebuild_switch(
363    repo: &Path,
364    hostname: &str,
365    platform: crate::discover::Platform,
366) -> Result<()> {
367    match platform {
368        crate::discover::Platform::Darwin => darwin_rebuild_switch(repo, hostname),
369        crate::discover::Platform::Linux => nixos_rebuild_switch(repo, hostname),
370    }
371}
372
373/// Ensure home-manager profile directories exist.
374/// Determinate Nix on fresh macOS doesn't create per-user profile dirs,
375/// which causes home-manager to fail with "Could not find suitable profile directory".
376/// Also fixes ~/.local ownership if the install script created it as root.
377pub fn ensure_profile_dirs() {
378    tracing::debug!("ensuring profile directories");
379    let user = std::env::var("USER")
380        .or_else(|_| std::env::var("LOGNAME"))
381        .unwrap_or_default();
382    if user.is_empty() {
383        eprintln!("  warning: USER not set, skipping profile directory setup");
384        return;
385    }
386
387    // Fix ~/.local ownership — the install script may have created it via sudo
388    if let Some(home) = dirs::home_dir() {
389        let dot_local = home.join(".local");
390        if dot_local.exists() {
391            if let Ok(meta) = std::fs::metadata(&dot_local) {
392                use std::os::unix::fs::MetadataExt;
393                if meta.uid() == 0 {
394                    if let Ok(s) = Command::new("sudo")
395                        .args(["chown", "-R", &user, &dot_local.to_string_lossy()])
396                        .status()
397                    {
398                        if !s.success() {
399                            eprintln!(
400                                "  warning: failed to fix ownership of {}",
401                                dot_local.display()
402                            );
403                        }
404                    }
405                }
406            }
407        }
408    }
409
410    let dirs = [
411        format!("/nix/var/nix/profiles/per-user/{user}"),
412        dirs::home_dir()
413            .map(|h| {
414                h.join(".local/state/nix/profiles")
415                    .to_string_lossy()
416                    .into_owned()
417            })
418            .unwrap_or_default(),
419    ];
420
421    for dir in &dirs {
422        if dir.is_empty() {
423            continue;
424        }
425        let path = Path::new(dir);
426        if path.exists() {
427            continue;
428        }
429        if dir.starts_with("/nix/") {
430            let ok = Command::new("sudo")
431                .args(["mkdir", "-p", dir])
432                .status()
433                .map(|s| s.success())
434                .unwrap_or(false)
435                && Command::new("sudo")
436                    .args(["chown", &user, dir])
437                    .status()
438                    .map(|s| s.success())
439                    .unwrap_or(false);
440            if !ok {
441                eprintln!("  warning: failed to create {dir}");
442            }
443        } else if std::fs::create_dir_all(path).is_err() {
444            eprintln!("  warning: failed to create {dir}");
445        }
446    }
447}
448
449/// Re-register nix .app bundles with LaunchServices so Spotlight shows correct icons.
450fn refresh_app_icons() {
451    let lsregister = "/System/Library/Frameworks/CoreServices.framework/\
452                      Frameworks/LaunchServices.framework/Support/lsregister";
453
454    if !Path::new(lsregister).exists() {
455        return;
456    }
457
458    let app_dirs: Vec<std::path::PathBuf> = [
459        dirs::home_dir().map(|h| h.join("Applications/Home Manager Apps")),
460        Some(std::path::PathBuf::from("/Applications/Nix Apps")),
461    ]
462    .into_iter()
463    .flatten()
464    .filter(|d| d.exists())
465    .collect();
466
467    if app_dirs.is_empty() {
468        return;
469    }
470
471    for dir in &app_dirs {
472        let entries = match std::fs::read_dir(dir) {
473            Ok(e) => e,
474            Err(_) => continue,
475        };
476        for entry in entries.flatten() {
477            let path = entry.path();
478            if path.extension().and_then(|e| e.to_str()) == Some("app") {
479                let _ = Command::new(lsregister).args(["-f"]).arg(&path).output();
480            }
481        }
482    }
483}
484
485/// Run darwin-rebuild build (for diff). No sudo needed — build only.
486pub fn darwin_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
487    let dr = find_darwin_rebuild()?;
488    let flake = format!(".#{hostname}");
489    run(Command::new(&dr)
490        .arg("build")
491        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
492        .args(["--flake", &flake])
493        .current_dir(repo))
494}
495
496/// Run nixos-rebuild build (for diff). No sudo needed — build only.
497pub fn nixos_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
498    let nr = find_nixos_rebuild()?;
499    let flake = format!(".#{hostname}");
500    run(Command::new(&nr)
501        .arg("build")
502        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
503        .args(["--flake", &flake])
504        .current_dir(repo))
505}
506
507/// Platform-aware system rebuild build.
508pub fn system_rebuild_build(
509    repo: &Path,
510    hostname: &str,
511    platform: crate::discover::Platform,
512) -> Result<()> {
513    match platform {
514        crate::discover::Platform::Darwin => darwin_rebuild_build(repo, hostname),
515        crate::discover::Platform::Linux => nixos_rebuild_build(repo, hostname),
516    }
517}
518
519/// Run darwin-rebuild --rollback (requires sudo for system activation).
520pub fn darwin_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
521    let dr = find_darwin_rebuild()?;
522    let flake = format!(".#{hostname}");
523    run(Command::new("sudo")
524        .arg(&dr)
525        .arg("switch")
526        .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
527        .args(["--rollback", "--flake", &flake])
528        .current_dir(repo))
529}
530
531/// Run nixos-rebuild --rollback (requires sudo for system activation).
532pub fn nixos_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
533    let nr = find_nixos_rebuild()?;
534    let flake = format!(".#{hostname}");
535    run(Command::new("sudo")
536        .arg(&nr)
537        .arg("switch")
538        .args(rebuild_experimental_args(crate::discover::Platform::Linux))
539        .args(["--rollback", "--flake", &flake])
540        .current_dir(repo))
541}
542
543/// Platform-aware system rebuild rollback.
544pub fn system_rebuild_rollback(
545    repo: &Path,
546    hostname: &str,
547    platform: crate::discover::Platform,
548) -> Result<()> {
549    match platform {
550        crate::discover::Platform::Darwin => darwin_rebuild_rollback(repo, hostname),
551        crate::discover::Platform::Linux => nixos_rebuild_rollback(repo, hostname),
552    }
553}
554
555/// Run nix flake update.
556pub fn nix_flake_update(repo: &Path) -> Result<()> {
557    run(nix_command().args(["flake", "update"]).current_dir(repo))
558}
559
560/// Run nix shell for an ephemeral package.
561pub fn nix_shell(pkg: &str) -> Result<()> {
562    run(nix_command().args(["shell", &format!("nixpkgs#{pkg}")]))
563}
564
565/// Show diff between current system and new build.
566pub fn nix_diff_closures(repo: &Path) -> Result<()> {
567    run(nix_command()
568        .args([
569            "store",
570            "diff-closures",
571            "/nix/var/nix/profiles/system",
572            "./result",
573        ])
574        .current_dir(repo))
575}
576
577/// Garbage collect nix store and remove old generations.
578pub fn nix_gc() -> Result<()> {
579    let nix = find_nix();
580    run(nix_command().args(["store", "gc"]))?;
581    // Remove old generations — nix-collect-garbage -d, using the same bin dir as nix
582    let nix_path = std::path::PathBuf::from(&nix);
583    if let Some(bin_dir) = nix_path.parent() {
584        let gc_bin = bin_dir.join("nix-collect-garbage");
585        if gc_bin.exists() {
586            run(Command::new(&gc_bin).args(["-d"]))?;
587        }
588    }
589    Ok(())
590}
591
592#[cfg(test)]
593mod tests {
594    use super::rebuild_experimental_args;
595
596    #[test]
597    fn darwin_rebuild_uses_supported_experimental_feature_option() {
598        assert_eq!(
599            rebuild_experimental_args(crate::discover::Platform::Darwin),
600            ["--option", "experimental-features", "nix-command flakes"]
601        );
602    }
603
604    #[test]
605    fn nixos_rebuild_keeps_nixos_rebuild_experimental_feature_flag() {
606        assert_eq!(
607            rebuild_experimental_args(crate::discover::Platform::Linux),
608            ["--extra-experimental-features", "nix-command flakes"]
609        );
610    }
611}