diff --git a/docs/superpowers/plans/2026-03-31-notfiles-workspace.md b/docs/superpowers/plans/2026-03-31-notfiles-workspace.md new file mode 100644 index 0000000..5996bc3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-notfiles-workspace.md @@ -0,0 +1,1783 @@ +# notfiles Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the `notfiles` crate into a Cargo workspace of five focused crates (`notcore`, `notfiles`, `notsecrets`, `nothooks`, `notstrap`) that together replace the dotfiles shell-script ecosystem and provide a single-command new-machine bootstrap. + +**Architecture:** `notcore` is a pure library holding shared types; `notfiles` is the existing stow engine refactored to import from `notcore`; `notsecrets` retrieves the age key via Bitwarden/file/prompt and decrypts `secrets.sops.env`; `nothooks` runs bootstrap hooks in phases; `notstrap` orchestrates all of them on a fresh machine. + +**Tech Stack:** Rust 2024 edition, Cargo workspace, `clap` (derive), `serde`/`toml`, `thiserror`, `anyhow`, `globset`, `dirs`, `rpassword`, `which`, `tempfile`/`assert_fs` (tests) + +--- + +## File Map + +### New files created + +``` +Cargo.toml # workspace root +crates/notcore/Cargo.toml +crates/notcore/src/lib.rs # re-exports all notcore modules +crates/notcore/src/error.rs # NotfilesError (moved from src/error.rs) +crates/notcore/src/config.rs # Config, Defaults, PackageConfig, Method (moved) +crates/notcore/src/paths.rs # expand_tilde, dotfiles_dir (moved) +crates/notcore/src/types.rs # HookSpec, PackageSpec, Report, Step, HookPhase +crates/notfiles/Cargo.toml +crates/notfiles/src/lib.rs # pub mod + pub fn link(), unlink(), status() +crates/notfiles/src/main.rs # thin CLI wrapper (moved from src/main.rs) +crates/notfiles/src/cli.rs # Cli, Command (moved from src/cli.rs) +crates/notfiles/src/linker.rs # moved from src/linker.rs, imports notcore +crates/notfiles/src/package.rs # moved from src/package.rs, imports notcore +crates/notfiles/src/ignore.rs # moved from src/ignore.rs +crates/notfiles/src/status.rs # moved from src/status.rs, imports notcore +crates/notfiles/tests/integration.rs # moved from tests/integration.rs +crates/notsecrets/Cargo.toml +crates/notsecrets/src/lib.rs # AgeKeySource trait + resolve(), decrypt_sops() +crates/notsecrets/src/sources/mod.rs # re-exports all sources +crates/notsecrets/src/sources/bitwarden.rs # BitwardenSource +crates/notsecrets/src/sources/file.rs # FileSource +crates/notsecrets/src/sources/prompt.rs # PromptSource +crates/notsecrets/tests/integration.rs # integration tests with temp dirs +crates/nothooks/Cargo.toml +crates/nothooks/src/lib.rs # pub fn run_phase(), HookRunner +crates/nothooks/src/main.rs # CLI wrapper +crates/nothooks/src/runner.rs # execute_hook(), capture output +crates/nothooks/src/state.rs # HookState, .nothooks-state.toml persistence +crates/nothooks/tests/integration.rs +crates/notstrap/Cargo.toml +crates/notstrap/src/main.rs # orchestrates everything +crates/notstrap/src/prereqs.rs # check_prerequisites() +crates/notstrap/src/repo.rs # clone_if_missing() +``` + +### Files deleted after migration + +``` +src/main.rs +src/cli.rs +src/config.rs +src/error.rs +src/ignore.rs +src/linker.rs +src/package.rs +src/paths.rs +src/status.rs +tests/integration.rs +Cargo.toml (replaced by workspace root Cargo.toml) +``` + +--- + +## Task 1: Create workspace root and `notcore` crate + +**Files:** +- Create: `Cargo.toml` (workspace root, replaces existing) +- Create: `crates/notcore/Cargo.toml` +- Create: `crates/notcore/src/lib.rs` +- Create: `crates/notcore/src/error.rs` +- Create: `crates/notcore/src/config.rs` +- Create: `crates/notcore/src/paths.rs` +- Create: `crates/notcore/src/types.rs` + +- [ ] **Step 1: Rename existing Cargo.toml out of the way** + +```bash +mv Cargo.toml Cargo.toml.old +mv Cargo.lock Cargo.lock.old +``` + +- [ ] **Step 2: Create workspace root `Cargo.toml`** + +```toml +[workspace] +members = [ + "crates/notcore", + "crates/notfiles", + "crates/notsecrets", + "crates/nothooks", + "crates/notstrap", +] +resolver = "2" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +dirs = "6" +globset = "0.4" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +toml = "0.8" +which = "7" +rpassword = "0.7" +tempfile = "3" +assert_fs = "1" +notcore = { path = "crates/notcore" } +notfiles = { path = "crates/notfiles" } +notsecrets = { path = "crates/notsecrets" } +nothooks = { path = "crates/nothooks" } +``` + +- [ ] **Step 3: Create `crates/notcore/Cargo.toml`** + +```toml +[package] +name = "notcore" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +dirs = { workspace = true } +anyhow = { workspace = true } +``` + +- [ ] **Step 4: Create `crates/notcore/src/error.rs`** + +Copy `src/error.rs` verbatim, removing the `use crate::` imports (they're now in the same crate): + +```rust +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum NotfilesError { + #[error("config file error: {0}")] + Config(String), + + #[error("package not found: {name}")] + PackageNotFound { name: String }, + + #[error("conflict at {path}: {reason}")] + Conflict { path: PathBuf, reason: String }, + + #[error("path error: {0}")] + Path(String), + + #[error("state file error: {0}")] + State(String), + + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Other(String), +} +``` + +- [ ] **Step 5: Create `crates/notcore/src/config.rs`** + +Copy `src/config.rs` verbatim, replacing `use crate::error::NotfilesError` with `use crate::NotfilesError`: + +```rust +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +use crate::NotfilesError; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub packages: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Defaults { + #[serde(default = "default_target")] + pub target: String, + #[serde(default = "default_ignore")] + pub ignore: Vec, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + target: default_target(), + ignore: default_ignore(), + } + } +} + +fn default_target() -> String { + "~".to_string() +} + +fn default_ignore() -> Vec { + vec![ + ".git".to_string(), + ".DS_Store".to_string(), + "README.md".to_string(), + "LICENSE".to_string(), + "notfiles.toml".to_string(), + ".notfiles-state.toml".to_string(), + ] +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PackageConfig { + #[serde(default)] + pub method: Option, + pub target: Option, + #[serde(default)] + pub ignore: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Method { + #[default] + Symlink, + Copy, +} + +impl std::fmt::Display for Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Method::Symlink => write!(f, "symlink"), + Method::Copy => write!(f, "copy"), + } + } +} + +impl Config { + pub fn load(dotfiles_dir: &Path) -> Result { + let config_path = dotfiles_dir.join("notfiles.toml"); + if !config_path.exists() { + return Ok(Config::default()); + } + let content = std::fs::read_to_string(&config_path) + .map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?; + let config: Config = toml::from_str(&content) + .map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?; + Ok(config) + } + + pub fn method_for(&self, package: &str) -> Method { + self.packages + .get(package) + .and_then(|p| p.method) + .unwrap_or_default() + } + + pub fn target_for(&self, package: &str) -> &str { + self.packages + .get(package) + .and_then(|p| p.target.as_deref()) + .unwrap_or(&self.defaults.target) + } + + pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> { + let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect(); + if let Some(pkg) = self.packages.get(package) { + for p in &pkg.ignore { + patterns.push(p.as_str()); + } + } + patterns + } +} + +pub fn starter_toml() -> &'static str { + r#"[defaults] +target = "~" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml"] + +# [packages.ssh] +# method = "copy" +# ignore = ["known_hosts"] +# +# [packages.scripts] +# target = "~/bin" +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.defaults.target, "~"); + assert!(config.defaults.ignore.contains(&".git".to_string())); + assert_eq!(config.method_for("anything"), Method::Symlink); + assert_eq!(config.target_for("anything"), "~"); + } + + #[test] + fn test_parse_config() { + let toml_str = r#" +[defaults] +target = "~" +ignore = [".git"] + +[packages.ssh] +method = "copy" +ignore = ["known_hosts"] + +[packages.scripts] +target = "~/bin" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.method_for("ssh"), Method::Copy); + assert_eq!(config.method_for("scripts"), Method::Symlink); + assert_eq!(config.target_for("scripts"), "~/bin"); + assert_eq!(config.target_for("ssh"), "~"); + + let ssh_ignores = config.ignore_patterns_for("ssh"); + assert!(ssh_ignores.contains(&".git")); + assert!(ssh_ignores.contains(&"known_hosts")); + } +} +``` + +- [ ] **Step 6: Create `crates/notcore/src/paths.rs`** + +Copy `src/paths.rs` verbatim, replacing `use crate::error::NotfilesError` with `use crate::NotfilesError`: + +```rust +use std::path::PathBuf; + +use crate::NotfilesError; + +pub fn expand_tilde(path: &str) -> Result { + if path == "~" { + return dirs::home_dir() + .ok_or_else(|| NotfilesError::Path("cannot determine home directory".into())); + } + if let Some(rest) = path.strip_prefix("~/") { + let home = dirs::home_dir() + .ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()))?; + return Ok(home.join(rest)); + } + Ok(PathBuf::from(path)) +} + +pub fn dotfiles_dir() -> Option { + dirs::home_dir().map(|h| h.join("dotfiles")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_tilde_home() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~").unwrap(), home); + } + + #[test] + fn test_expand_tilde_subpath() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~/foo/bar").unwrap(), home.join("foo/bar")); + } + + #[test] + fn test_expand_tilde_absolute() { + assert_eq!(expand_tilde("/usr/bin").unwrap(), PathBuf::from("/usr/bin")); + } +} +``` + +- [ ] **Step 7: Create `crates/notcore/src/types.rs`** + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HookPhase { + Dot, + Setup, +} + +impl std::fmt::Display for HookPhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HookPhase::Dot => write!(f, "dot"), + HookPhase::Setup => write!(f, "setup"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookSpec { + pub name: String, + pub script: String, + pub phase: HookPhase, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackageSpec { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepStatus { + Ok, + Skipped, + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct Step { + pub name: String, + pub status: StepStatus, +} + +#[derive(Debug, Default)] +pub struct Report { + pub steps: Vec, +} + +impl Report { + pub fn add(&mut self, name: impl Into, status: StepStatus) { + self.steps.push(Step { name: name.into(), status }); + } + + pub fn print(&self) { + for step in &self.steps { + let icon = match &step.status { + StepStatus::Ok => "\x1b[32m✓\x1b[0m", + StepStatus::Skipped => "\x1b[33m-\x1b[0m", + StepStatus::Failed(_) => "\x1b[31m✗\x1b[0m", + }; + let detail = match &step.status { + StepStatus::Failed(msg) => format!(" ({msg})"), + _ => String::new(), + }; + println!("{icon} {}{detail}", step.name); + } + } + + pub fn has_failures(&self) -> bool { + self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_report_has_failures() { + let mut r = Report::default(); + r.add("step1", StepStatus::Ok); + assert!(!r.has_failures()); + r.add("step2", StepStatus::Failed("oops".into())); + assert!(r.has_failures()); + } + + #[test] + fn test_hook_phase_display() { + assert_eq!(HookPhase::Dot.to_string(), "dot"); + assert_eq!(HookPhase::Setup.to_string(), "setup"); + } +} +``` + +- [ ] **Step 8: Create `crates/notcore/src/lib.rs`** + +```rust +pub mod config; +pub mod error; +pub mod paths; +pub mod types; + +pub use config::{Config, Defaults, Method, PackageConfig}; +pub use error::NotfilesError; +pub use paths::{dotfiles_dir, expand_tilde}; +pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus}; +``` + +- [ ] **Step 9: Verify notcore builds and tests pass** + +```bash +cargo test -p notcore +``` + +Expected: all tests pass, no warnings. + +- [ ] **Step 10: Commit** + +```bash +git add Cargo.toml crates/notcore/ +git commit -m "feat(notcore): add shared types, config, paths, error crate" +``` + +--- + +## Task 2: Migrate `notfiles` into workspace crate + +**Files:** +- Create: `crates/notfiles/Cargo.toml` +- Create: `crates/notfiles/src/lib.rs` +- Move+adapt: `src/main.rs` → `crates/notfiles/src/main.rs` +- Move+adapt: `src/cli.rs` → `crates/notfiles/src/cli.rs` +- Move+adapt: `src/linker.rs` → `crates/notfiles/src/linker.rs` +- Move+adapt: `src/package.rs` → `crates/notfiles/src/package.rs` +- Move+adapt: `src/ignore.rs` → `crates/notfiles/src/ignore.rs` +- Move+adapt: `src/status.rs` → `crates/notfiles/src/status.rs` +- Move: `tests/integration.rs` → `crates/notfiles/tests/integration.rs` + +- [ ] **Step 1: Create `crates/notfiles/Cargo.toml`** + +```toml +[package] +name = "notfiles" +version = "0.1.0" +edition = "2024" +description = "A modern dotfiles manager — pure Rust alternative to GNU Stow" + +[[bin]] +name = "notfiles" +path = "src/main.rs" + +[lib] +name = "notfiles" +path = "src/lib.rs" + +[dependencies] +notcore = { workspace = true } +clap = { workspace = true } +globset = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +assert_fs = { workspace = true } +``` + +- [ ] **Step 2: Copy source files into `crates/notfiles/src/`** + +```bash +mkdir -p crates/notfiles/src crates/notfiles/tests +cp src/cli.rs crates/notfiles/src/cli.rs +cp src/linker.rs crates/notfiles/src/linker.rs +cp src/package.rs crates/notfiles/src/package.rs +cp src/ignore.rs crates/notfiles/src/ignore.rs +cp src/status.rs crates/notfiles/src/status.rs +cp tests/integration.rs crates/notfiles/tests/integration.rs +``` + +- [ ] **Step 3: Update imports in each copied file** + +In every file under `crates/notfiles/src/`, replace `use crate::error::` with `use notcore::` and `use crate::config::` with `use notcore::`, `use crate::paths::` with `use notcore::`. Concretely, the top of each file should import from `notcore` instead of `crate`: + +`crates/notfiles/src/linker.rs` — change: +```rust +// remove: +use crate::config::{Config, Method}; +use crate::error::NotfilesError; +use crate::paths::expand_tilde; +// add: +use notcore::{Config, Method, NotfilesError, expand_tilde}; +``` + +`crates/notfiles/src/package.rs` — change: +```rust +// remove: +use crate::config::Config; +use crate::error::NotfilesError; +use crate::ignore::IgnoreMatcher; +// add: +use notcore::{Config, NotfilesError}; +use crate::ignore::IgnoreMatcher; +``` + +`crates/notfiles/src/status.rs` — change: +```rust +// remove: +use crate::config::Config; +use crate::error::NotfilesError; +use crate::linker::State; +use crate::paths::expand_tilde; +// add: +use notcore::{Config, NotfilesError, expand_tilde}; +use crate::linker::State; +``` + +- [ ] **Step 4: Create `crates/notfiles/src/lib.rs`** + +```rust +pub mod cli; +pub mod ignore; +pub mod linker; +pub mod package; +pub mod status; + +use anyhow::Result; +use std::path::Path; + +pub use linker::{LinkOptions, State}; +pub use package::resolve_packages; + +pub fn link( + dotfiles_dir: &Path, + packages: &[String], + opts: &LinkOptions, +) -> Result { + let config = notcore::Config::load(dotfiles_dir)?; + let mut state = State::load(dotfiles_dir)?; + let pkgs = resolve_packages(dotfiles_dir, packages)?; + for pkg in &pkgs { + linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts)?; + } + state.save(dotfiles_dir)?; + Ok(state) +} + +pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> { + let mut state = State::load(dotfiles_dir)?; + let pkgs = if packages.is_empty() { + state + .entries + .iter() + .map(|e| e.package.clone()) + .collect::>() + .into_iter() + .collect::>() + } else { + packages.to_vec() + }; + for pkg in &pkgs { + linker::unlink_package(dotfiles_dir, &mut state, pkg, opts)?; + } + state.save(dotfiles_dir) +} +``` + +- [ ] **Step 5: Create `crates/notfiles/src/main.rs`** + +Copy `src/main.rs` verbatim, replacing `mod config; mod error; mod paths;` with `use notcore::{Config, expand_tilde};` and removing those three `mod` declarations. The remaining `mod` declarations (`mod cli; mod ignore; mod linker; mod package; mod status;`) move to `lib.rs` — update `main.rs` to pull from the lib: + +```rust +use anyhow::{Context, Result}; +use clap::Parser; +use std::fs; + +use notfiles::cli::{Cli, Command}; +use notfiles::linker::{LinkOptions, State}; +use notfiles::package::resolve_packages; +use notfiles::status; +use notfiles::linker; +use notcore::Config; + +fn main() -> Result<()> { + let cli = Cli::parse(); + let dotfiles_dir = cli + .dir + .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory")); + let dotfiles_dir = fs::canonicalize(&dotfiles_dir) + .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; + + match cli.command { + Command::Init => cmd_init(&dotfiles_dir)?, + Command::Link { force, no_backup, packages } => { + let config = Config::load(&dotfiles_dir)?; + let mut state = State::load(&dotfiles_dir)?; + let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + let opts = LinkOptions { force, no_backup, dry_run: cli.dry_run, verbose: cli.verbose }; + if cli.dry_run { println!("\x1b[36m(dry run)\x1b[0m"); } + for pkg in &pkgs { + if cli.verbose || cli.dry_run { println!("Linking {pkg}..."); } + linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts)?; + } + if !cli.dry_run { + state.save(&dotfiles_dir)?; + let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum(); + println!("\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m", + if count == 1 { "" } else { "s" }, + pkgs.len(), + if pkgs.len() == 1 { "" } else { "s" }); + } + } + Command::Unlink { packages } => { + let config = Config::load(&dotfiles_dir)?; + let _ = &config; + let mut state = State::load(&dotfiles_dir)?; + let pkgs = if packages.is_empty() { + state.entries.iter().map(|e| e.package.clone()) + .collect::>().into_iter().collect::>() + } else { packages }; + let opts = LinkOptions { force: false, no_backup: false, dry_run: cli.dry_run, verbose: cli.verbose }; + if cli.dry_run { println!("\x1b[36m(dry run)\x1b[0m"); } + for pkg in &pkgs { + if cli.verbose || cli.dry_run { println!("Unlinking {pkg}..."); } + linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts)?; + } + if !cli.dry_run { + state.save(&dotfiles_dir)?; + println!("\x1b[32mUnlinked {} package{}.\x1b[0m", pkgs.len(), if pkgs.len() == 1 { "" } else { "s" }); + } + } + Command::Status { packages } => { + let config = Config::load(&dotfiles_dir)?; + let state = State::load(&dotfiles_dir)?; + let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + for pkg in &pkgs { + let entries = status::package_status(&dotfiles_dir, &config, &state, pkg); + status::print_status(pkg, &entries); + } + } + } + Ok(()) +} + +fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> { + let config_path = dotfiles_dir.join("notfiles.toml"); + if config_path.exists() { + println!("notfiles.toml already exists."); + return Ok(()); + } + fs::write(&config_path, notcore::config::starter_toml())?; + println!("Created notfiles.toml"); + Ok(()) +} +``` + +- [ ] **Step 6: Build and test `notfiles`** + +```bash +cargo test -p notfiles +``` + +Expected: all tests pass (same as before the migration). + +- [ ] **Step 7: Delete old `src/` and old `Cargo.toml`** + +```bash +rm -rf src/ tests/ Cargo.toml.old Cargo.lock.old +``` + +- [ ] **Step 8: Run full workspace build** + +```bash +cargo build --workspace +``` + +Expected: builds cleanly with no errors. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(workspace): migrate notfiles into crates/ workspace layout" +``` + +--- + +## Task 3: Add `notsecrets` crate + +**Files:** +- Create: `crates/notsecrets/Cargo.toml` +- Create: `crates/notsecrets/src/lib.rs` +- Create: `crates/notsecrets/src/sources/mod.rs` +- Create: `crates/notsecrets/src/sources/bitwarden.rs` +- Create: `crates/notsecrets/src/sources/file.rs` +- Create: `crates/notsecrets/src/sources/prompt.rs` +- Create: `crates/notsecrets/tests/integration.rs` + +- [ ] **Step 1: Write failing test for `AgeKeySource` trait resolution** + +Create `crates/notsecrets/tests/integration.rs`: + +```rust +use std::fs; +use tempfile::TempDir; +use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; + +#[test] +fn test_file_source_reads_key() { + let dir = TempDir::new().unwrap(); + let key_file = dir.path().join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1ABCDEF\n").unwrap(); + + let source = FileSource::new(key_file.clone()); + let result = source.retrieve(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().trim(), "AGE-SECRET-KEY-1ABCDEF"); +} + +#[test] +fn test_file_source_missing_returns_err() { + let source = FileSource::new("/nonexistent/age.key".into()); + assert!(source.retrieve().is_err()); +} + +#[test] +fn test_resolve_age_key_uses_file_fallback() { + let dir = TempDir::new().unwrap(); + let key_file = dir.path().join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1TEST\n").unwrap(); + + // Only FileSource in the chain — should succeed + let sources: Vec> = vec![ + Box::new(FileSource::new(key_file)), + ]; + let key = resolve_age_key(sources).unwrap(); + assert_eq!(key.trim(), "AGE-SECRET-KEY-1TEST"); +} + +#[test] +fn test_resolve_age_key_all_fail_returns_err() { + let sources: Vec> = vec![ + Box::new(FileSource::new("/nonexistent".into())), + ]; + assert!(resolve_age_key(sources).is_err()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p notsecrets 2>&1 | head -20 +``` + +Expected: compile error — `notsecrets` crate does not exist yet. + +- [ ] **Step 3: Create `crates/notsecrets/Cargo.toml`** + +```toml +[package] +name = "notsecrets" +version = "0.1.0" +edition = "2024" + +[dependencies] +notcore = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +which = { workspace = true } +rpassword = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +``` + +- [ ] **Step 4: Create `crates/notsecrets/src/sources/file.rs`** + +```rust +use std::path::PathBuf; +use anyhow::Result; +use crate::AgeKeySource; + +pub struct FileSource { + path: PathBuf, +} + +impl FileSource { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl AgeKeySource for FileSource { + fn name(&self) -> &str { "file" } + + fn retrieve(&self) -> Result { + Ok(std::fs::read_to_string(&self.path) + .map_err(|e| anyhow::anyhow!("cannot read key file {}: {e}", self.path.display()))?) + } +} +``` + +- [ ] **Step 5: Create `crates/notsecrets/src/sources/prompt.rs`** + +```rust +use anyhow::Result; +use crate::AgeKeySource; + +pub struct PromptSource; + +impl AgeKeySource for PromptSource { + fn name(&self) -> &str { "prompt" } + + fn retrieve(&self) -> Result { + let key = rpassword::prompt_password("Paste your age private key: ") + .map_err(|e| anyhow::anyhow!("could not read age key from prompt: {e}"))?; + if key.trim().is_empty() { + anyhow::bail!("empty age key entered"); + } + Ok(key) + } +} +``` + +- [ ] **Step 6: Create `crates/notsecrets/src/sources/bitwarden.rs`** + +```rust +use anyhow::{bail, Result}; +use std::process::Command; +use which::which; +use crate::AgeKeySource; + +pub struct BitwardenSource { + /// Bitwarden item name containing the age key in its `notes` field. + pub item_name: String, +} + +impl BitwardenSource { + pub fn new(item_name: impl Into) -> Self { + Self { item_name: item_name.into() } + } +} + +impl AgeKeySource for BitwardenSource { + fn name(&self) -> &str { "bitwarden" } + + fn retrieve(&self) -> Result { + if which("bw").is_err() { + bail!("bw CLI not found in PATH"); + } + + // Check if session is already unlocked (BW_SESSION env var) + let session = std::env::var("BW_SESSION").unwrap_or_default(); + let session = if session.is_empty() { + // Prompt for master password + let password = rpassword::prompt_password("Bitwarden master password: ") + .map_err(|e| anyhow::anyhow!("could not read password: {e}"))?; + let output = Command::new("bw") + .args(["unlock", "--raw", &password]) + .output()?; + if !output.status.success() { + bail!("bw unlock failed: {}", String::from_utf8_lossy(&output.stderr)); + } + String::from_utf8(output.stdout)?.trim().to_string() + } else { + session + }; + + let output = Command::new("bw") + .args(["get", "notes", &self.item_name, "--session", &session]) + .output()?; + + if !output.status.success() { + bail!( + "bw get notes '{}' failed: {}", + self.item_name, + String::from_utf8_lossy(&output.stderr) + ); + } + + let key = String::from_utf8(output.stdout)?.trim().to_string(); + if key.is_empty() { + bail!("Bitwarden item '{}' has empty notes", self.item_name); + } + Ok(key) + } +} +``` + +- [ ] **Step 7: Create `crates/notsecrets/src/sources/mod.rs`** + +```rust +pub mod bitwarden; +pub mod file; +pub mod prompt; + +pub use bitwarden::BitwardenSource; +pub use file::FileSource; +pub use prompt::PromptSource; +``` + +- [ ] **Step 8: Create `crates/notsecrets/src/lib.rs`** + +```rust +pub mod sources; + +pub use sources::{BitwardenSource, FileSource, PromptSource}; + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Trait for retrieving an age private key from some source. +pub trait AgeKeySource { + fn name(&self) -> &str; + fn retrieve(&self) -> Result; +} + +/// Try each source in order; return the first success. +/// Returns an error if all sources fail. +pub fn resolve_age_key(sources: Vec>) -> Result { + let mut last_err = String::new(); + for source in sources { + match source.retrieve() { + Ok(key) => return Ok(key), + Err(e) => { + eprintln!(" [{}] {e}", source.name()); + last_err = format!("{e}"); + } + } + } + bail!("all age key sources failed; last error: {last_err}") +} + +/// Write the age key to `~/.config/sops/age/keys.txt` (mode 0600). +pub fn install_age_key(key: &str) -> Result { + let path = dirs::home_dir() + .context("cannot find home directory")? + .join(".config/sops/age/keys.txt"); + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(&path, key)?; + // chmod 600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(path) +} + +/// Run `sops --decrypt ` and return the decrypted content. +/// Requires `SOPS_AGE_KEY_FILE` env var to be set, or the key installed at the default path. +pub fn decrypt_sops(sops_file: &Path) -> Result { + let output = Command::new("sops") + .args(["--decrypt", sops_file.to_str().unwrap()]) + .output() + .context("failed to run sops")?; + + if !output.status.success() { + bail!("sops decrypt failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(String::from_utf8(output.stdout)?) +} +``` + +- [ ] **Step 9: Run tests** + +```bash +cargo test -p notsecrets +``` + +Expected: `test_file_source_reads_key`, `test_file_source_missing_returns_err`, `test_resolve_age_key_uses_file_fallback`, `test_resolve_age_key_all_fail_returns_err` all pass. + +- [ ] **Step 10: Commit** + +```bash +git add crates/notsecrets/ +git commit -m "feat(notsecrets): add age key retrieval with bw/file/prompt sources" +``` + +--- + +## Task 4: Add `nothooks` crate + +**Files:** +- Create: `crates/nothooks/Cargo.toml` +- Create: `crates/nothooks/src/lib.rs` +- Create: `crates/nothooks/src/main.rs` +- Create: `crates/nothooks/src/runner.rs` +- Create: `crates/nothooks/src/state.rs` +- Create: `crates/nothooks/tests/integration.rs` + +- [ ] **Step 1: Write failing tests** + +Create `crates/nothooks/tests/integration.rs`: + +```rust +use std::fs; +use tempfile::TempDir; +use nothooks::{HookRunner, HookResult}; +use notcore::{HookPhase, HookSpec}; + +fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec { + let path = dir.path().join(format!("{name}.sh")); + fs::write(&path, format!("#!/bin/sh\n{content}")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } + HookSpec { + name: name.to_string(), + script: path.to_str().unwrap().to_string(), + phase: HookPhase::Dot, + } +} + +#[test] +fn test_hook_success() { + let dir = TempDir::new().unwrap(); + let spec = make_hook_script(&dir, "ok-hook", "echo hello"); + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec); + assert!(matches!(result, HookResult::Ok)); +} + +#[test] +fn test_hook_failure() { + let dir = TempDir::new().unwrap(); + let spec = make_hook_script(&dir, "fail-hook", "exit 1"); + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec); + assert!(matches!(result, HookResult::Failed(_))); +} + +#[test] +fn test_setup_hook_skipped_on_rerun() { + let dir = TempDir::new().unwrap(); + let mut spec = make_hook_script(&dir, "setup-hook", "echo ran"); + spec.phase = notcore::HookPhase::Setup; + + let runner = HookRunner::new(dir.path().to_path_buf()); + let r1 = runner.run_hook(&spec); + assert!(matches!(r1, HookResult::Ok)); + + // Second run — should be skipped + let r2 = runner.run_hook(&spec); + assert!(matches!(r2, HookResult::Skipped)); +} + +#[test] +fn test_setup_hook_force_reruns() { + let dir = TempDir::new().unwrap(); + let mut spec = make_hook_script(&dir, "force-hook", "echo ran"); + spec.phase = notcore::HookPhase::Setup; + + let runner = HookRunner::new(dir.path().to_path_buf()); + runner.run_hook(&spec); + + let runner2 = HookRunner::with_force(dir.path().to_path_buf()); + let r2 = runner2.run_hook(&spec); + assert!(matches!(r2, HookResult::Ok)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p nothooks 2>&1 | head -20 +``` + +Expected: compile error. + +- [ ] **Step 3: Create `crates/nothooks/Cargo.toml`** + +```toml +[package] +name = "nothooks" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "nothooks" +path = "src/main.rs" + +[lib] +name = "nothooks" +path = "src/lib.rs" + +[dependencies] +notcore = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +clap = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +``` + +- [ ] **Step 4: Create `crates/nothooks/src/state.rs`** + +```rust +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +const STATE_FILE: &str = ".nothooks-state.toml"; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct HookState { + pub completed_setup_hooks: HashSet, +} + +impl HookState { + pub fn load(dir: &Path) -> Result { + let path = dir.join(STATE_FILE); + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&content)?) + } + + pub fn save(&self, dir: &Path) -> Result<()> { + let path = dir.join(STATE_FILE); + std::fs::write(path, toml::to_string(self)?)?; + Ok(()) + } + + pub fn mark_done(&mut self, name: &str) { + self.completed_setup_hooks.insert(name.to_string()); + } + + pub fn is_done(&self, name: &str) -> bool { + self.completed_setup_hooks.contains(name) + } +} +``` + +- [ ] **Step 5: Create `crates/nothooks/src/runner.rs`** + +```rust +use std::path::PathBuf; +use std::process::Command; +use notcore::HookSpec; +use crate::HookResult; +use crate::state::HookState; +use notcore::HookPhase; + +pub struct HookRunner { + state_dir: PathBuf, + force: bool, +} + +impl HookRunner { + pub fn new(state_dir: PathBuf) -> Self { + Self { state_dir, force: false } + } + + pub fn with_force(state_dir: PathBuf) -> Self { + Self { state_dir, force: true } + } + + pub fn run_hook(&self, spec: &HookSpec) -> HookResult { + let mut state = HookState::load(&self.state_dir).unwrap_or_default(); + + if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) { + return HookResult::Skipped; + } + + let result = Command::new("sh") + .arg("-c") + .arg(&spec.script) + .status(); + + match result { + Ok(status) if status.success() => { + if spec.phase == HookPhase::Setup { + state.mark_done(&spec.name); + let _ = state.save(&self.state_dir); + } + HookResult::Ok + } + Ok(status) => HookResult::Failed(format!("exit code {}", status.code().unwrap_or(-1))), + Err(e) => HookResult::Failed(e.to_string()), + } + } +} +``` + +- [ ] **Step 6: Create `crates/nothooks/src/lib.rs`** + +```rust +pub mod runner; +pub mod state; + +pub use runner::HookRunner; +use notcore::{HookPhase, HookSpec, Report, StepStatus}; + +#[derive(Debug, PartialEq)] +pub enum HookResult { + Ok, + Skipped, + Failed(String), +} + +/// Run all hooks matching `phase` and collect into a `Report`. +pub fn run_phase( + hooks: &[HookSpec], + phase: &HookPhase, + runner: &HookRunner, +) -> Report { + let mut report = Report::default(); + for hook in hooks.iter().filter(|h| &h.phase == phase) { + let result = runner.run_hook(hook); + let status = match &result { + HookResult::Ok => StepStatus::Ok, + HookResult::Skipped => StepStatus::Skipped, + HookResult::Failed(msg) => StepStatus::Failed(msg.clone()), + }; + report.add(&hook.name, status); + } + report +} +``` + +- [ ] **Step 7: Create `crates/nothooks/src/main.rs`** + +```rust +use anyhow::Result; +use clap::{Parser, Subcommand}; +use nothooks::{HookRunner, run_phase}; +use notcore::{HookPhase, HookSpec}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "nothooks", about = "Bootstrap hook runner")] +struct Cli { + #[command(subcommand)] + command: Cmd, + + /// Force re-run of setup hooks + #[arg(long, global = true)] + force: bool, + + /// Path to hooks config TOML + #[arg(long, global = true, default_value = "notstrap.toml")] + config: PathBuf, + + /// Directory for state file (default: current dir) + #[arg(long, global = true)] + state_dir: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// Run hooks for a phase + Run { + /// Phase to run: dot or setup + #[arg(long)] + phase: String, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let state_dir = cli.state_dir + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + let content = std::fs::read_to_string(&cli.config) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", cli.config.display()))?; + + #[derive(serde::Deserialize)] + struct HooksFile { hooks: Vec } + let file: HooksFile = toml::from_str(&content)?; + + let phase = match cli.command { + Cmd::Run { ref phase } => match phase.as_str() { + "dot" => HookPhase::Dot, + "setup" => HookPhase::Setup, + other => anyhow::bail!("unknown phase '{other}', use dot or setup"), + }, + }; + + let runner = if cli.force { + HookRunner::with_force(state_dir) + } else { + HookRunner::new(state_dir) + }; + + let report = run_phase(&file.hooks, &phase, &runner); + report.print(); + + if report.has_failures() { + std::process::exit(1); + } + Ok(()) +} +``` + +- [ ] **Step 8: Run tests** + +```bash +cargo test -p nothooks +``` + +Expected: all 4 integration tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add crates/nothooks/ +git commit -m "feat(nothooks): add phase-aware hook runner with setup-hook state tracking" +``` + +--- + +## Task 5: Add `notstrap` crate + +**Files:** +- Create: `crates/notstrap/Cargo.toml` +- Create: `crates/notstrap/src/main.rs` +- Create: `crates/notstrap/src/prereqs.rs` +- Create: `crates/notstrap/src/repo.rs` + +- [ ] **Step 1: Create `crates/notstrap/Cargo.toml`** + +```toml +[package] +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 } +``` + +- [ ] **Step 2: Create `crates/notstrap/src/prereqs.rs`** + +```rust +use anyhow::Result; +use which::which; + +pub struct Prereq { + pub cmd: &'static str, + pub install_hint: &'static str, +} + +const PREREQS: &[Prereq] = &[ + 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" }, +]; + +/// Check for required tools. Returns list of missing tools with hints. +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()) +} +``` + +- [ ] **Step 3: Create `crates/notstrap/src/repo.rs`** + +```rust +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +/// Clone `url` to `dest` if `dest` doesn't already exist. +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) +} +``` + +- [ ] **Step 4: Create `crates/notstrap/src/main.rs`** + +```rust +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 std::path::PathBuf; +use serde::Deserialize; + +#[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 crate::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 crate::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 decrypt secrets + 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(FileSource::new( + notsecrets::install_age_key("").map(|_| PathBuf::new()).unwrap_or_default() + )), + 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) => { + // Inject decrypted vars into current process environment + 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('#') { + 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 ok = phase_report.steps.iter().filter(|s| matches!(s.status, notcore::StepStatus::Ok)).count(); + let skipped = phase_report.steps.iter().filter(|s| matches!(s.status, notcore::StepStatus::Skipped)).count(); + 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 + }; + let _ = (ok, skipped); + 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(()) +} + +mod prereqs; +mod repo; +``` + +- [ ] **Step 5: Build notstrap** + +```bash +cargo build -p notstrap +``` + +Expected: builds cleanly. + +- [ ] **Step 6: Run full workspace test suite** + +```bash +cargo test --workspace +``` + +Expected: all tests pass across all crates. + +- [ ] **Step 7: Commit** + +```bash +git add crates/notstrap/ +git commit -m "feat(notstrap): add new-machine bootstrap orchestrator" +``` + +--- + +## Task 6: Cleanup and workspace polish + +**Files:** +- Delete: `Cargo.toml.old`, `Cargo.lock.old` (if not already removed) +- Modify: `Cargo.toml` (workspace root) — add `[profile]` settings + +- [ ] **Step 1: Add release profile to workspace Cargo.toml** + +Add to the end of `Cargo.toml`: + +```toml +[profile.release] +opt-level = 3 +lto = "thin" +strip = true +``` + +- [ ] **Step 2: Run clippy across workspace** + +```bash +cargo clippy --workspace -- -D warnings +``` + +Fix any warnings before continuing. + +- [ ] **Step 3: Run full test suite one final time** + +```bash +cargo test --workspace +``` + +Expected: all tests pass. + +- [ ] **Step 4: Final commit** + +```bash +git add -A +git commit -m "chore(workspace): add release profile, clean up old files" +``` + +--- + +## Self-Review Notes + +**Spec coverage:** +- ✓ notcore (error, config, paths, types) +- ✓ notfiles migrated to workspace crate with lib + bin +- ✓ notsecrets with bw/file/prompt sources, AgeKeySource trait, install_age_key, decrypt_sops +- ✓ nothooks with dot/setup phases, state persistence, force flag +- ✓ notstrap orchestrating all crates in correct order +- ✓ Migration path (old src/ deleted after crates/ established) + +**Type consistency check:** +- `LinkOptions` used in Task 2 lib.rs and Task 5 main.rs ✓ +- `HookRunner::new` / `HookRunner::with_force` defined in Task 4 and used in Task 5 ✓ +- `AgeKeySource` trait defined in notsecrets lib.rs and used in notstrap ✓ +- `Report` / `StepStatus` from notcore used consistently ✓ +- `HookPhase::Dot` / `HookPhase::Setup` from notcore used consistently ✓ +- `state.entries` referenced in notstrap Task 5 — `State.entries` field is public in linker.rs ✓ (verify during implementation) + +**One known item to verify during Task 2:** the existing `linker.rs` and `status.rs` have many internal `use crate::` references — Step 3 of Task 2 calls out the pattern but the implementor should do a full `grep 'use crate::' crates/notfiles/src/` pass after copying files to catch any missed imports.