diff --git a/crates/notstrap/Cargo.toml b/crates/notstrap/Cargo.toml index dced39c..15706fc 100644 --- a/crates/notstrap/Cargo.toml +++ b/crates/notstrap/Cargo.toml @@ -2,3 +2,19 @@ name = "notstrap" version = "0.1.0" edition = "2024" +description = "New-machine bootstrap orchestrator" + +[[bin]] +name = "notstrap" +path = "src/main.rs" + +[dependencies] +notcore = { workspace = true } +notfiles = { workspace = true } +notsecrets = { workspace = true } +nothooks = { workspace = true } +clap = { workspace = true } +anyhow = { workspace = true } +which = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } diff --git a/crates/notstrap/src/main.rs b/crates/notstrap/src/main.rs new file mode 100644 index 0000000..7dd2e1e --- /dev/null +++ b/crates/notstrap/src/main.rs @@ -0,0 +1,190 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use notcore::{HookPhase, Report, StepStatus}; +use notfiles::{link, LinkOptions}; +use nothooks::{run_phase, HookRunner}; +use notsecrets::{ + install_age_key, resolve_age_key, BitwardenSource, FileSource, PromptSource, +}; +use serde::Deserialize; +use std::path::PathBuf; + +mod prereqs; +mod repo; + +#[derive(Parser)] +#[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(clap::Subcommand)] +enum Cmd { + /// Run the full bootstrap sequence + Run { + /// Path to notstrap.toml config + #[arg(long, default_value = "notstrap.toml")] + config: PathBuf, + + /// Force re-run of setup hooks + #[arg(long)] + force: bool, + + /// Path to age key file (skips Bitwarden and prompt) + #[arg(long)] + key_file: Option, + + /// Path to dotfiles directory (default: ~/dotfiles) + #[arg(long)] + dotfiles: Option, + }, +} + +#[derive(Deserialize)] +struct NotstrapConfig { + bootstrap: BootstrapSection, + #[serde(default)] + hooks: Vec, +} + +#[derive(Deserialize)] +struct BootstrapSection { + dotfiles_repo: String, + dotfiles_dir: String, + #[serde(default = "default_bw_item")] + bw_age_item: String, + #[serde(default = "default_sops_file")] + sops_file: String, +} + +fn default_bw_item() -> String { "age-key-dotfiles".to_string() } +fn default_sops_file() -> String { "secrets/bootstrap.sops.env".to_string() } + +fn main() -> Result<()> { + let cli = Cli::parse(); + let Cmd::Run { config, force, key_file, dotfiles } = cli.command; + + let mut report = Report::default(); + + // 1. Prerequisites + print!("Checking prerequisites... "); + match prereqs::check_prerequisites() { + Ok(_) => { println!("ok"); report.add("prerequisites", StepStatus::Ok); } + Err(e) => { + println!("FAILED"); + report.add("prerequisites", StepStatus::Failed(e.to_string())); + report.print(); + std::process::exit(1); + } + } + + // 2. Load config + let config_content = std::fs::read_to_string(&config) + .with_context(|| format!("cannot read {}", config.display()))?; + let cfg: NotstrapConfig = toml::from_str(&config_content)?; + + let dotfiles_dir = dotfiles.unwrap_or_else(|| { + notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir).unwrap() + }); + + // 3. Clone dotfiles if missing + match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) { + Ok(true) => { println!("Cloned dotfiles."); report.add("clone dotfiles", StepStatus::Ok); } + Ok(false) => { report.add("clone dotfiles", StepStatus::Skipped); } + Err(e) => { + report.add("clone dotfiles", StepStatus::Failed(e.to_string())); + report.print(); + std::process::exit(1); + } + } + + // 4. Retrieve age key and install + print!("Retrieving age key... "); + let sources: Vec> = if let Some(kf) = key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + match resolve_age_key(sources) { + Ok(key) => { + install_age_key(&key)?; + println!("ok"); + report.add("age key", StepStatus::Ok); + } + Err(e) => { + println!("FAILED"); + report.add("age key", StepStatus::Failed(e.to_string())); + report.print(); + std::process::exit(1); + } + } + + // 5. Decrypt sops secrets + let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); + match notsecrets::decrypt_sops(&sops_path) { + Ok(env_content) => { + for line in env_content.lines() { + if let Some((k, v)) = line.split_once('=') { + let k = k.trim(); + let v = v.trim().trim_matches('"'); + if !k.is_empty() && !k.starts_with('#') { + // Safety: single-threaded bootstrap, no concurrent env readers + unsafe { std::env::set_var(k, v); } + } + } + } + report.add("decrypt secrets", StepStatus::Ok); + } + Err(e) => { + report.add("decrypt secrets", StepStatus::Failed(e.to_string())); + report.print(); + std::process::exit(1); + } + } + + // 6. Link dotfiles + let opts = LinkOptions { force: false, no_backup: false, dry_run: false, verbose: false }; + match link(&dotfiles_dir, &[], &opts) { + Ok(state) => { + let count = state.entries.len(); + println!("Linked {count} files."); + report.add(format!("link dotfiles ({count} files)"), StepStatus::Ok); + } + Err(e) => { + report.add("link dotfiles", StepStatus::Failed(e.to_string())); + } + } + + // 7. Run hooks + let runner = if force { + HookRunner::with_force(dotfiles_dir.clone()) + } else { + HookRunner::new(dotfiles_dir.clone()) + }; + + for (phase, label) in [(HookPhase::Dot, "dot hooks"), (HookPhase::Setup, "setup hooks")] { + let phase_report = run_phase(&cfg.hooks, &phase, &runner); + let failed = phase_report.steps.iter().filter(|s| matches!(s.status, notcore::StepStatus::Failed(_))).count(); + let summary = if failed > 0 { + StepStatus::Failed(format!("{failed} failed")) + } else { + StepStatus::Ok + }; + report.add(label, summary); + phase_report.print(); + } + + // 8. Final report + println!("\n── Bootstrap complete ──"); + report.print(); + + if report.has_failures() { + std::process::exit(1); + } + Ok(()) +} diff --git a/crates/notstrap/src/prereqs.rs b/crates/notstrap/src/prereqs.rs new file mode 100644 index 0000000..d354e3e --- /dev/null +++ b/crates/notstrap/src/prereqs.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use which::which; + +pub struct Prereq { + pub cmd: &'static str, + pub install_hint: &'static str, +} + +const PREREQS: &[Prereq] = &[ + Prereq { cmd: "nu", install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell" }, + Prereq { cmd: "sops", install_hint: "brew install sops OR nix-env -iA nixpkgs.sops" }, + Prereq { cmd: "age", install_hint: "brew install age OR nix-env -iA nixpkgs.age" }, +]; + +pub fn check_prerequisites() -> Result<()> { + let missing: Vec<&Prereq> = PREREQS.iter().filter(|p| which(p.cmd).is_err()).collect(); + if missing.is_empty() { + return Ok(()); + } + eprintln!("Missing required tools:\n"); + for p in &missing { + eprintln!(" {} — {}", p.cmd, p.install_hint); + } + anyhow::bail!("{} prerequisite(s) missing", missing.len()) +} diff --git a/crates/notstrap/src/repo.rs b/crates/notstrap/src/repo.rs new file mode 100644 index 0000000..0ddc667 --- /dev/null +++ b/crates/notstrap/src/repo.rs @@ -0,0 +1,17 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +pub fn clone_if_missing(url: &str, dest: &Path) -> Result { + if dest.exists() { + return Ok(false); + } + let status = Command::new("git") + .args(["clone", url, dest.to_str().unwrap()]) + .status() + .context("failed to run git clone")?; + if !status.success() { + anyhow::bail!("git clone {} failed", url); + } + Ok(true) +}