All hook scripts use .nu extension and run via `nu <script>`.
nothooks runner uses Command::new("nu") instead of sh -c.
nu added to notstrap prerequisites check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50 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)
Convention: All hook scripts are Nushell (
.nu). No.shscripts.nothooksexecutes hooks vianu <script>, notsh -c.
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.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
[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 oldCargo.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
AgeKeySourcetrait 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}.nu"));
fs::write(&path, content).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", "print 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\n");
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", "print 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", "print 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("nu")
.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: "nu", install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell" },
Prereq { cmd: "sops", install_hint: "brew install sops OR nix-env -iA nixpkgs.sops" },
Prereq { cmd: "age", install_hint: "brew install age OR nix-env -iA nixpkgs.age" },
];
/// 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:
LinkOptionsused in Task 2 lib.rs and Task 5 main.rs ✓HookRunner::new/HookRunner::with_forcedefined in Task 4 and used in Task 5 ✓AgeKeySourcetrait defined in notsecrets lib.rs and used in notstrap ✓Report/StepStatusfrom notcore used consistently ✓HookPhase::Dot/HookPhase::Setupfrom notcore used consistently ✓state.entriesreferenced in notstrap Task 5 —State.entriesfield 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.