diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf32e6 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# notfiles + +A pure-Rust dotfiles manager — and eventually, a complete new-machine bootstrap system. + +`notfiles` started as a Rust replacement for [GNU Stow](https://www.gnu.org/software/stow/). It's growing into a **Cargo workspace** of focused crates that together replace an entire shell-script-based dotfiles ecosystem. + +--- + +## Quick Start + +```bash +# Symlink your dotfiles +notfiles link + +# Check status +notfiles status + +# Remove symlinks +notfiles unlink +``` + +On a new machine (once `notstrap` ships): + +```bash +cargo install notstrap +notstrap run +``` + +--- + +## Workspace Architecture + +``` +notfiles/ +├── crates/ +│ ├── notcore/ # shared types, config, paths, errors +│ ├── notfiles/ # symlink engine (stow replacement) ← you are here +│ ├── notsecrets/ # age key retrieval + sops decrypt +│ ├── nothooks/ # hook execution engine +│ └── notstrap/ # new-machine bootstrap orchestrator +├── notfiles.toml # symlink package config +└── notstrap.toml # bootstrap hook/phase config +``` + +### Crate responsibilities + +| Crate | Type | Does | +|-------|------|------| +| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates | +| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state | +| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets | +| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks | +| `notstrap` | bin | Orchestrate everything on a fresh machine | + +### Dependency graph + +``` +notstrap + ├── notfiles + │ └── notcore + ├── notsecrets + │ └── notcore + └── nothooks + └── notcore +``` + +`notcore` is the only shared dependency. No circular deps. + +--- + +## How `notfiles` Works + +Each subdirectory of your dotfiles repo is a **package**. `notfiles link` walks each package and symlinks its contents into a target directory (default: `$HOME`), mirroring the directory structure. + +``` +dotfiles/ +└── zsh/ + └── .zshrc → symlink → ~/.zshrc + +dotfiles/ +└── git/ + └── .config/ + └── git/ + └── config → symlink → ~/.config/git/config +``` + +State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly what was linked, when, and how. + +### Link flow + +``` +notfiles link + │ + ├─ resolve_packages() discover subdirs or validate requested names + │ + ├─ collect_files() recursive walk, apply ignore patterns (globset) + │ + ├─ conflict_check() existing file? symlink to wrong target? + │ + └─ linker::link_package() create symlinks (or copies), write state +``` + +### State file + +`.notfiles-state.toml` records every linked file: + +```toml +[[entries]] +source = "/Users/joe/dotfiles/zsh/.zshrc" +target = "/Users/joe/.zshrc" +method = "symlink" +package = "zsh" +linked_at = "2026-03-31T10:00:00Z" +``` + +This powers `status` (diff expected vs actual) and `unlink` (clean removal with empty-parent cleanup). + +--- + +## New Machine Bootstrap (notstrap) + +The hardest part of a new machine is the chicken-and-egg problem: you need secrets to set up the machine, but secrets live in an encrypted file that requires a key you haven't retrieved yet. + +`notstrap` solves this with a staged bootstrap: + +``` +notstrap run + │ + ├─ 1. Prerequisites check + │ Is bw/sops/age available? Print exactly what's missing and stop. + │ + ├─ 2. notsecrets — retrieve age key + │ ├─ try: Bitwarden CLI (bw unlock) + │ ├─ fallback: --key-file (USB drive) + │ └─ fallback: interactive prompt (paste key) + │ └─ sops decrypt secrets.sops.env → env injected + │ (now op, bw, github, openai, anthropic tokens are live) + │ + ├─ 3. Clone dotfiles repo (if not present) + │ + ├─ 4. notfiles link — stow all packages + │ + ├─ 5. nothooks --phase dot + │ shell config, git config, AI tool configs (~seconds, re-runnable) + │ + ├─ 6. nothooks --phase setup + │ Homebrew/Nix packages, mise runtimes, dev tools, op install (~minutes, once) + │ + └─ 7. Report + ✓ linked 142 files ✓ 3 dot hooks ✓ 7 setup hooks +``` + +Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after secrets are already available via sops. It takes over secret management for day-to-day use once the machine is live. + +--- + +## Secrets Bootstrap Detail + +`notsecrets` implements an `AgeKeySource` trait with three sources tried in order: + +``` +AgeKeySource + ├── BitwardenSource bw unlock → session token → bw get item age-key + ├── FileSource read from --key-file path (USB, etc.) + └── PromptSource read from stdin (paste) +``` + +Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and `sops` decrypts `secrets.sops.env`. The decrypted file contains all critical bootstrap credentials (op, bw, github, openai, anthropic, etc.) and is injected into the environment for subsequent hooks. + +--- + +## Hook Phases + +`nothooks` runs hooks in two phases defined in `notstrap.toml`: + +| Phase | Speed | Re-runnable | Examples | +|-------|-------|-------------|---------| +| `dot` | ~seconds | Yes | shell config, git config, AI tool configs | +| `setup` | ~minutes | No (tracked) | Homebrew packages, mise runtimes, op install | + +`setup` hooks are tracked in `.nothooks-state.toml` — already-run hooks are skipped unless `--force` is passed. `dot` hooks always re-run (they're idempotent by design). + +--- + +## Configuration + +### `notfiles.toml` — symlink config + +```toml +[defaults] +method = "symlink" +target = "~" + +[packages.secrets] +method = "copy" # copy instead of symlink for sensitive files + +[packages.work] +target = "~/work" # different target dir +ignore = ["*.local"] +``` + +### `notstrap.toml` — bootstrap config + +```toml +[bootstrap] +dotfiles_repo = "git@github.com:you/dotfiles.git" +dotfiles_dir = "~/dotfiles" + +[[hooks]] +name = "shell-config" +script = "scripts/setup-git-config.sh" +phase = "dot" + +[[hooks]] +name = "homebrew-packages" +script = "scripts/setup-packages.sh" +phase = "setup" +``` + +--- + +## Migration from dotfiles/ + +Migration is gradual — shell scripts are replaced as Rust equivalents ship: + +| Phase | Ships | Replaces | +|-------|-------|---------| +| 1 | `notfiles` workspace (now) | GNU Stow | +| 2 | `notsecrets` + `notstrap` skeleton | `setup-secrets.sh`, `install.sh` | +| 3 | `nothooks` | `bootstrap.sh` hook runner | +| 4 | Hook-by-hook Rust rewrites | Individual `setup-*.sh` scripts | +| 5 | `dotfiles/` archived | — | + +--- + +## Development + +```bash +cargo build # build all crates +cargo test # run all tests +cargo test -p notfiles # test one crate +cargo clippy --workspace # lint everything +cargo fmt --check # format check +``` diff --git a/docs/superpowers/specs/2026-03-31-notfiles-workspace-architecture-design.md b/docs/superpowers/specs/2026-03-31-notfiles-workspace-architecture-design.md new file mode 100644 index 0000000..2de2e65 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-notfiles-workspace-architecture-design.md @@ -0,0 +1,195 @@ +# notfiles Workspace Architecture Design + +**Date:** 2026-03-31 +**Status:** Approved + +## Overview + +`notfiles` is a pure-Rust dotfiles manager replacing GNU Stow. This design expands it into a **Cargo workspace** of focused crates that together replace the entire `dotfiles/` shell script ecosystem — including bootstrapping a new machine from scratch. + +The primary pain points being solved: + +- New machine setup requires too many manual steps before the bootstrap script even runs +- Secrets/1Password setup is fragile — keys aren't available early enough +- Too many shell scripts that are hard to maintain and test +- Nix/Homebrew split is unclear; Rust tools are preferred + +--- + +## Workspace Structure + +``` +notfiles/ # Cargo workspace root +├── Cargo.toml # [workspace] members +├── notfiles.toml # dotfiles symlink config +├── notstrap.toml # bootstrap hook/phase config +├── crates/ +│ ├── notcore/ # shared types, config, paths, errors +│ ├── notfiles/ # symlink engine (stow replacement) +│ ├── notsecrets/ # age key retrieval + sops decrypt +│ ├── nothooks/ # hook execution engine +│ └── notstrap/ # new-machine bootstrap orchestrator +└── tests/ # workspace-level integration tests +``` + +### Crate Summary + +| Crate | Type | Role | +|-------|------|------| +| `notcore` | lib | Shared types, config, paths, errors | +| `notfiles` | lib + bin | Symlink engine (stow replacement) | +| `notsecrets` | lib | Age key retrieval → sops decrypt | +| `nothooks` | lib + bin | Hook execution, phase tracking | +| `notstrap` | bin | Orchestrates everything on a new machine | + +--- + +## Crate Designs + +### `notcore` — Shared Foundation + +Pure library. No binary. Every other crate depends on it; it depends on nothing in the workspace. + +**Contents:** +- `NotfilesError` — unified error enum via `thiserror` +- `Config` — parses `notfiles.toml` + `notstrap.toml` +- `Paths` — `expand_tilde`, `dotfiles_dir()`, standard path resolution +- `HookSpec` — name, script path, phase (`dot` | `setup`) +- `PackageSpec` — name, link method (`symlink` | `copy`), target dir +- `Report` / `Step` — shared types for the end-of-run summary table + +**Migration from current code:** `config.rs`, `paths.rs`, `error.rs` move from the current `notfiles/src/` into `notcore`. + +--- + +### `notfiles` — Symlink Engine + +Library + binary. The stow replacement. Operates on a dotfiles directory of "packages" (subdirectories) and symlinks their contents into a target (typically `$HOME`). + +**Responsibilities:** +- `link` — create symlinks or copies from package dirs to target +- `unlink` — remove symlinks, clean up empty parent dirs +- `status` — diff expected state (config + disk) vs actual state (symlinks + state file) +- `State` — serialize/deserialize `.notfiles-state.toml` (tracks every linked file) +- Conflict detection and `--force` backup handling +- Glob-based ignore matching via `globset` + +**Migration from current code:** `linker.rs`, `package.rs`, `ignore.rs`, `status.rs` stay in `notfiles`. Config/paths/error types are imported from `notcore`. + +Used as a library by `notstrap` (`notfiles::link()` called directly). + +--- + +### `notsecrets` — Secrets Bootstrap + +Library. Retrieves the age private key and decrypts `secrets.sops.env` before anything else runs. + +**Age key sources (tried in order):** + +1. **Bitwarden CLI** (`bw`) — non-interactive if session is cached; prompts for master password if not +2. **File** — `--key-file ` (USB drive, external storage) +3. **Interactive prompt** — user pastes the age key directly + +Implemented as a port: `AgeKeySource` trait with three implementations (`BitwardenSource`, `FileSource`, `PromptSource`). `notstrap` iterates sources until one succeeds. + +Once the age key is in hand: writes it to `~/.config/sops/age/keys.txt`, then runs `sops --decrypt secrets.sops.env` to produce a live env file. The decrypted env contains all critical credentials: op, bw, github, openai, anthropic, etc. + +1Password (`op`) is NOT used at this stage — it's installed later as a hook, after secrets are already available. + +--- + +### `nothooks` — Hook Execution Engine + +Library + binary. Replaces `run_hook`, `run_dot_hooks`, `run_setup_hooks` from `bootstrap.sh`. + +**Hook phases:** +- `dot` — fast, re-runnable (shell config, git config, AI tool configs) +- `setup` — slow, run-once (package installs, language runtimes, dev tools) + +**Responsibilities:** +- Read hook specs from `notstrap.toml` — ordered list with name, script path, phase +- Execute hooks in declared order, capturing stdout/stderr +- Track which `setup` hooks have already run in `.nothooks-state.toml` — skip on re-run unless `--force` +- Feed per-hook pass/skip/fail results into `Report` from `notcore` + +**Does NOT handle:** package management (invoked as hooks), secret handling (done before hooks run). + +CLI: `nothooks run --phase dot` or `nothooks run --phase setup` + +--- + +### `notstrap` — New Machine Orchestrator + +Binary only. The single entry point on a fresh machine. + +**Install:** +```bash +cargo install notstrap +notstrap run +``` + +**Execution order:** + +1. Check prerequisites (`op`, `bw`, `sops`, `age` — print exactly what's missing and stop) +2. Run `notsecrets` — retrieve age key → decrypt `secrets.sops.env` → inject into env +3. Clone dotfiles repo if not present (URL from `notstrap.toml`) +4. Run `notfiles link` — stow all packages +5. Run `nothooks run --phase dot` — fast hooks +6. Run `nothooks run --phase setup` — slow hooks (includes `op` install + sign-in) +7. Print `Report` summary table + +--- + +## Day-One Flow + +``` +cargo install notstrap +notstrap run + └─ notsecrets + ├─ try: bw unlock → age key + ├─ fallback: --key-file + └─ fallback: interactive prompt + └─ sops decrypt secrets.sops.env → env injected + └─ notfiles link (all packages) + └─ nothooks --phase dot + └─ shell config, git config, AI tool configs (~seconds) + └─ nothooks --phase setup + └─ Homebrew/Nix packages, mise runtimes, dev tools, op install (~minutes) + └─ Report: ✓ linked 142 files, ✓ 3 dot hooks, ✓ 7 setup hooks +``` + +--- + +## Migration Strategy + +Migration from `dotfiles/` is gradual — shell scripts die as their Rust equivalents ship: + +| Phase | What ships | What it replaces | +|-------|-----------|-----------------| +| 1 | `notfiles` workspace (this design) | GNU Stow | +| 2 | `notsecrets` + `notstrap` skeleton | `setup-secrets.sh`, `install.sh` | +| 3 | `nothooks` | `bootstrap.sh` hook runner | +| 4 | Hook-by-hook Rust rewrites | Individual `setup-*.sh` scripts | +| 5 | `dotfiles/` repo archived | — | + +Existing `.rs` scripts (`drift-check.rs`, `redact-audit.rs`, `claude-sessions.rs`, etc.) can migrate into `nothooks` hook scripts or dedicated crates as needed. + +--- + +## Dependencies + +| Crate | Key dependencies | +|-------|-----------------| +| `notcore` | `serde`, `toml`, `thiserror`, `dirs`, `anyhow` | +| `notfiles` | `notcore`, `clap`, `globset`, `chrono` | +| `notsecrets` | `notcore`, `clap`, `rpassword`, `which` | +| `nothooks` | `notcore`, `clap`, `serde`, `toml` | +| `notstrap` | `notcore`, `notfiles`, `notsecrets`, `nothooks`, `clap` | + +--- + +## Open Questions + +- Should `notstrap` be published to crates.io, or installed from the dotfiles repo directly via `cargo install --path`? +- Should `notsecrets` support a fourth source: a second Bitwarden-compatible backend (Vaultwarden self-hosted)? +- Hook scripts: keep as shell scripts invoked by `nothooks`, or migrate each to a Rust binary over time?