use anyhow::{Context, Result};
use console::style;
use serde::Serialize;
use crate::cli::DoctorScope;
use crate::config::Config;
use crate::{bootstrap, homebrew_bootstrap, output};
const HOST_READINESS_SCHEMA: &str = "io.styrene.nex.host-readiness.v1";
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HostReadinessReport {
schema: &'static str,
platform: String,
repo: String,
hostname: String,
checks: Vec<ReadinessCheck>,
summary: ReadinessSummary,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReadinessCheck {
id: &'static str,
status: ReadinessStatus,
severity: ReadinessSeverity,
message: String,
suggested_action: Option<String>,
read_only: bool,
}
#[derive(Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum ReadinessStatus {
Ok,
Warning,
Missing,
}
#[derive(Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum ReadinessSeverity {
Info,
Warning,
Blocker,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReadinessSummary {
ok: usize,
warnings: usize,
blockers: usize,
}
fn run_json(config: &Config) -> Result<()> {
let checks = readiness_checks(config)?;
let summary = summarize_readiness(&checks);
let report = HostReadinessReport {
schema: HOST_READINESS_SCHEMA,
platform: format!("{:?}", config.platform).to_ascii_lowercase(),
repo: config.repo.display().to_string(),
hostname: config.hostname.clone(),
checks,
summary,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn readiness_checks(config: &Config) -> Result<Vec<ReadinessCheck>> {
let mut checks = Vec::new();
checks.push(command_check(
"git",
&["--version"],
"Install git / Xcode Command Line Tools",
));
checks.push(command_check(
"nix",
&["--version"],
"Install Nix, then rerun nex init",
));
if config.platform == crate::discover::Platform::Darwin {
checks.push(command_check(
"xcode-select",
&["-p"],
"Run `xcode-select --install`",
));
let brew_exists = crate::homebrew_bootstrap::expected_brew_binary_exists();
checks.push(ReadinessCheck {
id: "homebrew-expected-prefix",
status: if brew_exists {
ReadinessStatus::Ok
} else {
ReadinessStatus::Warning
},
severity: if brew_exists {
ReadinessSeverity::Info
} else {
ReadinessSeverity::Warning
},
message: if brew_exists {
"expected Homebrew binary exists".to_string()
} else {
"expected Homebrew binary is missing; nix-homebrew may bootstrap it on first switch"
.to_string()
},
suggested_action: (!brew_exists).then(|| {
"Run `nex switch`; if Homebrew is bootstrapped, run it once more".to_string()
}),
read_only: true,
});
}
checks.push(path_check(
"config-repo",
config.repo.exists(),
format!("config repo {}", config.repo.display()),
"Run `nex init`".to_string(),
));
checks.push(path_check(
"homebrew-module",
config.homebrew_file.exists(),
format!("homebrew module {}", config.homebrew_file.display()),
"Run `nex init` or repair the config repo".to_string(),
));
Ok(checks)
}
fn command_check(
command: &'static str,
args: &[&str],
suggested_action: &'static str,
) -> ReadinessCheck {
let status = std::process::Command::new(command)
.args(args)
.output()
.map(|output| output.status.success())
.unwrap_or(false);
ReadinessCheck {
id: command,
status: if status {
ReadinessStatus::Ok
} else {
ReadinessStatus::Missing
},
severity: if status {
ReadinessSeverity::Info
} else {
ReadinessSeverity::Blocker
},
message: if status {
format!("{command} is available")
} else {
format!("{command} is missing or not functional")
},
suggested_action: (!status).then(|| suggested_action.to_string()),
read_only: true,
}
}
fn path_check(
id: &'static str,
exists: bool,
message: String,
suggested_action: String,
) -> ReadinessCheck {
ReadinessCheck {
id,
status: if exists {
ReadinessStatus::Ok
} else {
ReadinessStatus::Missing
},
severity: if exists {
ReadinessSeverity::Info
} else {
ReadinessSeverity::Blocker
},
message,
suggested_action: (!exists).then_some(suggested_action),
read_only: true,
}
}
fn summarize_readiness(checks: &[ReadinessCheck]) -> ReadinessSummary {
ReadinessSummary {
ok: checks
.iter()
.filter(|check| check.status == ReadinessStatus::Ok)
.count(),
warnings: checks
.iter()
.filter(|check| check.severity == ReadinessSeverity::Warning)
.count(),
blockers: checks
.iter()
.filter(|check| check.severity == ReadinessSeverity::Blocker)
.count(),
}
}
pub fn run(config: &Config, fix: bool, json: bool, scope: Option<DoctorScope>) -> Result<()> {
if json {
return run_json(config);
}
if matches!(scope, Some(DoctorScope::DarwinBootstrap)) {
return check_bootstrap(config, fix);
}
if matches!(scope, Some(DoctorScope::HomebrewBootstrap)) {
return homebrew_bootstrap::doctor(config, fix);
}
tracing::info!(fix, ?scope, "running doctor checks");
println!();
println!(" {} — checking configuration", style("nex doctor").bold());
println!();
check_identity();
let mut fixed = 0;
check_bootstrap(config, fix)?;
homebrew_bootstrap::doctor(config, fix)?;
if check_mac_app_util(config, &mut fixed)? {
}
check_allow_unfree(config, &mut fixed)?;
check_session_path(config, &mut fixed)?;
if fixed > 0 {
crate::exec::git_commit(&config.repo, "nex doctor: apply fixes");
println!();
println!(
" {} {fixed} issue(s) fixed. Run {} to activate.",
style("✓").green().bold(),
style("nex switch").bold()
);
} else {
println!(" {} no issues found", style("✓").green().bold());
}
println!();
Ok(())
}
fn check_bootstrap(config: &Config, fix: bool) -> Result<()> {
let Some(report) = bootstrap::check(config.platform)? else {
return Ok(());
};
if !report.has_blockers() {
ok("darwin bootstrap", "ready");
return Ok(());
}
bootstrap::print_recommendations(&report);
if fix {
bootstrap::repair(&report)?;
}
Ok(())
}
fn check_mac_app_util(config: &Config, fixed: &mut usize) -> Result<bool> {
let flake_path = config.repo.join("flake.nix");
let mkhost_path = config.repo.join("nix/lib/mkHost.nix");
let flake = std::fs::read_to_string(&flake_path)
.with_context(|| format!("reading {}", flake_path.display()))?;
if flake.contains("mac-app-util") {
ok("mac-app-util", "Spotlight app aliases enabled");
return Ok(false);
}
warn(
"mac-app-util",
"not configured — nix apps won't appear in Spotlight",
);
let lines: Vec<&str> = flake.lines().collect();
let mut result_lines: Vec<String> = Vec::new();
let mut added_input = false;
let mut patched_outputs = false;
let mut patched_inherit = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !added_input && trimmed == "};" {
let is_inputs_close = lines[i + 1..]
.iter()
.take(3)
.any(|l| l.trim().starts_with("outputs"));
if is_inputs_close {
result_lines
.push(" mac-app-util.url = \"github:hraban/mac-app-util\";".to_string());
added_input = true;
}
}
if !patched_outputs
&& trimmed.starts_with("outputs")
&& trimmed.contains("home-manager")
&& !trimmed.contains("mac-app-util")
{
let patched = line.replace("home-manager }", "home-manager, mac-app-util }");
let patched = patched.replace("home-manager }:", "home-manager, mac-app-util }:");
result_lines.push(patched);
patched_outputs = true;
continue;
}
if !patched_inherit
&& trimmed.starts_with("inherit")
&& trimmed.contains("home-manager")
&& !trimmed.contains("mac-app-util")
{
let patched = line.replace("home-manager;", "home-manager mac-app-util;");
result_lines.push(patched);
patched_inherit = true;
continue;
}
result_lines.push(line.to_string());
}
let mut patched_flake = result_lines.join("\n");
if flake.ends_with('\n') && !patched_flake.ends_with('\n') {
patched_flake.push('\n');
}
if !patched_flake.contains("mac-app-util") {
output::warn("could not auto-patch flake.nix — manual edit required");
return Ok(false);
}
let has_input = patched_flake.contains("mac-app-util.url");
let has_output = patched_flake.contains("mac-app-util }");
let has_inherit = patched_flake.contains("mac-app-util;");
if !has_input || !has_output || !has_inherit {
output::warn(
"could not fully patch flake.nix — partial changes would break the flake.\n\
Add mac-app-util manually: https://github.com/hraban/mac-app-util",
);
return Ok(false);
}
crate::edit::atomic_write_bytes(&flake_path, patched_flake.as_bytes())
.with_context(|| format!("writing {}", flake_path.display()))?;
info("patched", &flake_path.display().to_string());
if mkhost_path.exists() {
let mkhost = std::fs::read_to_string(&mkhost_path)
.with_context(|| format!("reading {}", mkhost_path.display()))?;
if !mkhost.contains("mac-app-util") {
let patched = mkhost
.replace(
"{ nixpkgs, nix-darwin, home-manager }:",
"{ nixpkgs, nix-darwin, home-manager, mac-app-util }:",
)
.replace(
" hostModule\n home-manager.darwinModules.home-manager",
" hostModule\n mac-app-util.darwinModules.default\n home-manager.darwinModules.home-manager",
)
.replace(
" extraSpecialArgs = { inherit hostname username; };\n };",
" extraSpecialArgs = { inherit hostname username; };\n sharedModules = [\n mac-app-util.homeManagerModules.default\n ];\n };",
);
crate::edit::atomic_write_bytes(&mkhost_path, patched.as_bytes())
.with_context(|| format!("writing {}", mkhost_path.display()))?;
info("patched", &mkhost_path.display().to_string());
}
}
tracing::info!(fix = "mac-app-util", "applied fix");
*fixed += 1;
Ok(true)
}
fn check_allow_unfree(config: &Config, fixed: &mut usize) -> Result<bool> {
let base_path = config.repo.join("nix/modules/darwin/base.nix");
if !base_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&base_path)
.with_context(|| format!("reading {}", base_path.display()))?;
if content.contains("allowUnfree") {
ok("unfree packages", "nixpkgs.config.allowUnfree is set");
return Ok(false);
}
warn(
"unfree packages",
"not allowed — vscode, slack, spotify, etc. will fail to install",
);
let patched = if content.contains("nix.enable = false;") {
content.replace(
"nix.enable = false;",
"nix.enable = false;\n\n nixpkgs.config.allowUnfree = true;",
)
} else if content.contains("nix.settings.experimental-features") {
content.replace(
"nix.settings.experimental-features",
"nixpkgs.config.allowUnfree = true;\n\n nix.settings.experimental-features",
)
} else {
output::warn(
"could not auto-patch base.nix — add `nixpkgs.config.allowUnfree = true;` manually",
);
return Ok(false);
};
crate::edit::atomic_write_bytes(&base_path, patched.as_bytes())
.with_context(|| format!("writing {}", base_path.display()))?;
info("patched", &base_path.display().to_string());
tracing::info!(fix = "allow-unfree", "applied fix");
*fixed += 1;
Ok(true)
}
fn check_session_path(config: &Config, fixed: &mut usize) -> Result<bool> {
let base_path = &config.nix_packages_file;
if !base_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(base_path)
.with_context(|| format!("reading {}", base_path.display()))?;
let in_nix_config = content.contains("sessionPath");
let on_path = is_local_bin_on_path();
if in_nix_config && on_path {
ok("sessionPath", "~/.local/bin is on PATH");
return Ok(false);
}
if in_nix_config && !on_path {
warn(
"sessionPath",
"configured in nix but ~/.local/bin is not on PATH — run `nex switch` then open a new shell",
);
return Ok(false);
}
warn(
"sessionPath",
"~/.local/bin not in PATH — nex may not be found after install",
);
let patched = if content.contains("stateVersion =") {
content.replace(
"stateVersion =",
"sessionPath = [ \"$HOME/.local/bin\" ];\n stateVersion =",
)
} else {
output::warn(
"could not auto-patch — add `home.sessionPath = [ \"$HOME/.local/bin\" ];` manually",
);
return Ok(false);
};
crate::edit::atomic_write_bytes(base_path, patched.as_bytes())
.with_context(|| format!("writing {}", base_path.display()))?;
info("patched", &base_path.display().to_string());
tracing::info!(fix = "session-path", "applied fix");
*fixed += 1;
Ok(true)
}
fn is_local_bin_on_path() -> bool {
let path_var = std::env::var("PATH").unwrap_or_default();
let home = std::env::var("HOME").unwrap_or_default();
let expanded = format!("{home}/.local/bin");
path_var
.split(':')
.any(|entry| entry == "~/.local/bin" || entry == "$HOME/.local/bin" || entry == expanded)
}
fn check_identity() {
let identity_path = styrene_identity::file_signer::FileSigner::default_path();
if !identity_path.exists() {
warn("identity", "no identity file — run `nex identity init`");
} else {
match std::fs::metadata(&identity_path) {
Ok(meta) => {
if meta.len() != 97 {
warn(
"identity",
&format!("unexpected file size ({} bytes, expected 97)", meta.len()),
);
} else {
ok("identity", &format!("{}", identity_path.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 {
warn(
"identity permissions",
&format!("{:#o} — should be 0o600", mode),
);
}
}
}
Err(e) => warn("identity", &format!("cannot read: {e}")),
}
}
let gpg_format = std::process::Command::new("git")
.args(["config", "--global", "gpg.format"])
.output();
match gpg_format {
Ok(ref out) if crate::exec::captured_text(&out.stdout).trim() == "ssh" => {
ok("git signing", "gpg.format = ssh");
}
_ => {
info("git signing", "not configured — run `nex identity git`");
}
}
match crate::config::load_identity_config() {
Ok(id_config) => {
let labels = id_config.ssh.and_then(|s| s.labels).unwrap_or_default();
if labels.is_empty() {
info(
"ssh labels",
"none registered — try `nex identity ssh --add github`",
);
} else {
ok(
"ssh labels",
&format!("{}: {}", labels.len(), labels.join(", ")),
);
}
}
Err(_) => {
info("ssh labels", "no identity config");
}
}
}
fn ok(label: &str, detail: &str) {
eprintln!(
" {} {}: {}",
style("✓").green().bold(),
label,
style(detail).dim()
);
}
fn warn(label: &str, detail: &str) {
eprintln!(
" {} {}: {}",
style("!").yellow().bold(),
label,
style(detail).dim()
);
}
fn info(label: &str, detail: &str) {
eprintln!(" {} {}: {}", style("→").cyan(), label, style(detail).dim());
}