Files
notfiles/docs/superpowers/plans/2026-03-31-notfiles-workspace.md
Joseph O'Brien c099bafd6c docs: add workspace implementation plan
6-task plan covering notcore extraction, notfiles workspace migration,
notsecrets (bw/file/prompt age key sources), nothooks (phase runner),
and notstrap (new-machine orchestrator).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:58:03 -04:00

49 KiB

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

mv Cargo.toml Cargo.toml.old
mv Cargo.lock Cargo.lock.old
  • Step 2: Create workspace root Cargo.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
[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):

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:

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<String, PackageConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Defaults {
    #[serde(default = "default_target")]
    pub target: String,
    #[serde(default = "default_ignore")]
    pub ignore: Vec<String>,
}

impl Default for Defaults {
    fn default() -> Self {
        Self {
            target: default_target(),
            ignore: default_ignore(),
        }
    }
}

fn default_target() -> String {
    "~".to_string()
}

fn default_ignore() -> Vec<String> {
    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<Method>,
    pub target: Option<String>,
    #[serde(default)]
    pub ignore: Vec<String>,
}

#[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<Self, NotfilesError> {
        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:

use std::path::PathBuf;

use crate::NotfilesError;

pub fn expand_tilde(path: &str) -> Result<PathBuf, NotfilesError> {
    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<PathBuf> {
    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
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<Step>,
}

impl Report {
    pub fn add(&mut self, name: impl Into<String>, 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
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
cargo test -p notcore

Expected: all tests pass, no warnings.

  • Step 10: Commit
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.rscrates/notfiles/src/main.rs

  • Move+adapt: src/cli.rscrates/notfiles/src/cli.rs

  • Move+adapt: src/linker.rscrates/notfiles/src/linker.rs

  • Move+adapt: src/package.rscrates/notfiles/src/package.rs

  • Move+adapt: src/ignore.rscrates/notfiles/src/ignore.rs

  • Move+adapt: src/status.rscrates/notfiles/src/status.rs

  • Move: tests/integration.rscrates/notfiles/tests/integration.rs

  • Step 1: Create crates/notfiles/Cargo.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/
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:

// 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:

// 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:

// 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
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<State> {
    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::<std::collections::HashSet<_>>()
            .into_iter()
            .collect::<Vec<_>>()
    } 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:

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::<std::collections::HashSet<_>>().into_iter().collect::<Vec<_>>()
            } 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
cargo test -p notfiles

Expected: all tests pass (same as before the migration).

  • Step 7: Delete old src/ and old Cargo.toml
rm -rf src/ tests/ Cargo.toml.old Cargo.lock.old
  • Step 8: Run full workspace build
cargo build --workspace

Expected: builds cleanly with no errors.

  • Step 9: Commit
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:

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<Box<dyn AgeKeySource>> = 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<Box<dyn AgeKeySource>> = vec![
        Box::new(FileSource::new("/nonexistent".into())),
    ];
    assert!(resolve_age_key(sources).is_err());
}
  • Step 2: Run test to verify it fails
cargo test -p notsecrets 2>&1 | head -20

Expected: compile error — notsecrets crate does not exist yet.

  • Step 3: Create crates/notsecrets/Cargo.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
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<String> {
        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
use anyhow::Result;
use crate::AgeKeySource;

pub struct PromptSource;

impl AgeKeySource for PromptSource {
    fn name(&self) -> &str { "prompt" }

    fn retrieve(&self) -> Result<String> {
        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
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<String>) -> Self {
        Self { item_name: item_name.into() }
    }
}

impl AgeKeySource for BitwardenSource {
    fn name(&self) -> &str { "bitwarden" }

    fn retrieve(&self) -> Result<String> {
        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
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
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<String>;
}

/// Try each source in order; return the first success.
/// Returns an error if all sources fail.
pub fn resolve_age_key(sources: Vec<Box<dyn AgeKeySource>>) -> Result<String> {
    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<PathBuf> {
    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 <sops_file>` 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<String> {
    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
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
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:

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
cargo test -p nothooks 2>&1 | head -20

Expected: compile error.

  • Step 3: Create crates/nothooks/Cargo.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
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<String>,
}

impl HookState {
    pub fn load(dir: &Path) -> Result<Self> {
        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
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
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
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<PathBuf>,
}

#[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<HookSpec> }
    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
cargo test -p nothooks

Expected: all 4 integration tests pass.

  • Step 9: Commit
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

[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
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
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<bool> {
    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
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<PathBuf>,

        /// Path to dotfiles directory (default: ~/dotfiles)
        #[arg(long)]
        dotfiles: Option<PathBuf>,
    },
}

#[derive(Deserialize)]
struct NotstrapConfig {
    bootstrap: BootstrapSection,
    #[serde(default)]
    hooks: Vec<notcore::HookSpec>,
}

#[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<Box<dyn notsecrets::AgeKeySource>> = 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
cargo build -p notstrap

Expected: builds cleanly.

  • Step 6: Run full workspace test suite
cargo test --workspace

Expected: all tests pass across all crates.

  • Step 7: Commit
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:

[profile.release]
opt-level = 3
lto = "thin"
strip = true
  • Step 2: Run clippy across workspace
cargo clippy --workspace -- -D warnings

Fix any warnings before continuing.

  • Step 3: Run full test suite one final time
cargo test --workspace

Expected: all tests pass.

  • Step 4: Final commit
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.