# 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.