From 1545a913c5b00c81b1097ded2922312060984608 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:57:32 -0400 Subject: [PATCH] refactor(notstrap): extract run() into lib with BootstrapOptions --- crates/notstrap/Cargo.toml | 4 + crates/notstrap/src/lib.rs | 155 ++++++++++++++++++++++++++++++++- crates/notstrap/src/main.rs | 165 +++--------------------------------- 3 files changed, 171 insertions(+), 153 deletions(-) diff --git a/crates/notstrap/Cargo.toml b/crates/notstrap/Cargo.toml index 428a1db..a31b702 100644 --- a/crates/notstrap/Cargo.toml +++ b/crates/notstrap/Cargo.toml @@ -9,6 +9,10 @@ description = "New-machine bootstrap orchestrator" name = "notstrap" path = "src/main.rs" +[lib] +name = "notstrap" +path = "src/lib.rs" + [dependencies] notcore = { workspace = true } notfiles = { workspace = true } diff --git a/crates/notstrap/src/lib.rs b/crates/notstrap/src/lib.rs index ff7bd09..5dfc447 100644 --- a/crates/notstrap/src/lib.rs +++ b/crates/notstrap/src/lib.rs @@ -1 +1,154 @@ -// placeholder +use anyhow::{Context, Result}; +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::{Path, PathBuf}; + +pub mod prereqs; +pub mod repo; + +#[derive(Deserialize)] +pub struct NotstrapConfig { + pub bootstrap: BootstrapSection, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Deserialize)] +pub struct BootstrapSection { + pub dotfiles_repo: String, + pub dotfiles_dir: String, + #[serde(default = "default_bw_item")] + pub bw_age_item: String, + #[serde(default = "default_sops_file")] + pub sops_file: String, +} + +pub fn default_bw_item() -> String { "age-key-dotfiles".to_string() } +pub fn default_sops_file() -> String { "secrets/bootstrap.sops.env".to_string() } + +pub struct BootstrapOptions { + pub config: PathBuf, + pub force: bool, + pub key_file: Option, + pub dotfiles: Option, + /// None = skip prereq check (tests). Some(f) = run f(). + pub check_prereqs: Option Result<()>>>, + /// None = skip env injection (tests). Some(f) = decrypt sops at path and inject. + pub env_injector: Option Result>>, +} + +pub fn run(opts: BootstrapOptions) -> Result { + let mut report = Report::default(); + + // 1. Prerequisites + if let Some(check) = opts.check_prereqs { + match check() { + Ok(_) => { report.add("prerequisites", StepStatus::Ok); } + Err(e) => { + report.add("prerequisites", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + } + + // 2. Load config + let config_content = std::fs::read_to_string(&opts.config) + .with_context(|| format!("cannot read {}", opts.config.display()))?; + let cfg: NotstrapConfig = toml::from_str(&config_content)?; + + let dotfiles_dir = opts.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) => { 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())); + return Ok(report); + } + } + + // 4. Retrieve age key and install + let sources: Vec> = if let Some(kf) = opts.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)?; + report.add("age key", StepStatus::Ok); + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + + // 5. Decrypt sops secrets (optional) + if let Some(injector) = opts.env_injector { + let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); + match injector(&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())); + return Ok(report); + } + } + } + + // 6. Link dotfiles + let link_opts = LinkOptions { force: opts.force, no_backup: false, dry_run: false, verbose: false }; + match link(&dotfiles_dir, &[], &link_opts) { + Ok(state) => { + let count = state.entries.len(); + 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 opts.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); + } + + Ok(report) +} diff --git a/crates/notstrap/src/main.rs b/crates/notstrap/src/main.rs index 7dd2e1e..dc3164c 100644 --- a/crates/notstrap/src/main.rs +++ b/crates/notstrap/src/main.rs @@ -1,16 +1,6 @@ -use anyhow::{Context, Result}; +use anyhow::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; +use notstrap::{prereqs, run, BootstrapOptions}; #[derive(Parser)] #[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")] @@ -25,7 +15,7 @@ enum Cmd { Run { /// Path to notstrap.toml config #[arg(long, default_value = "notstrap.toml")] - config: PathBuf, + config: std::path::PathBuf, /// Force re-run of setup hooks #[arg(long)] @@ -33,156 +23,27 @@ enum Cmd { /// Path to age key file (skips Bitwarden and prompt) #[arg(long)] - key_file: Option, + key_file: Option, /// Path to dotfiles directory (default: ~/dotfiles) #[arg(long)] - dotfiles: Option, + 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), - ] + let opts = BootstrapOptions { + config, + force, + key_file, + dotfiles, + check_prereqs: Some(Box::new(prereqs::check_prerequisites)), + env_injector: Some(Box::new(|p| notsecrets::decrypt_sops(p))), }; - - 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 ──"); + let report = run(opts)?; report.print(); - if report.has_failures() { std::process::exit(1); }