12 Commits

Author SHA1 Message Date
6427d188e6 feat(notfiles): hexagonal architecture refactor with adapters, integration tests, and notsecrets cleanup
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters
- Add Reporter trait to notcore with TerminalReporter and JsonReporter
- Refactor linker, package, status modules to use FileStore port
- Add integration test suite with dotfiles fixtures
- Add cross-crate integration tests
- Remove ssh_rsa identity/recipient (unsupported)
- Add rustqual.toml, .sccignore, notfiles.toml config files
- Update CLAUDE.md, README.md, AGENTS.md with current architecture
2026-07-08 13:07:42 -04:00
12ad322608 feat(nushell): add x alias for cargo xtask
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
2026-07-07 05:43:59 -04:00
cb732c24ce chore: add TODO roadmap, nf alias, and install recipe
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
2026-06-14 00:35:39 -04:00
268c981545 feat(notfiles): add detect subcommand for stow/chezmoi/notfiles auto-detection
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
2026-06-14 00:30:27 -04:00
32cb5f7516 feat(nushell): adopt full nushell config from dotfiles
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Moves config.nu, env.nu, and all autoload/*.nu files into the
notfiles nushell package. All symlinks in ~/.config/nushell/ now
point to notfiles instead of dotfiles.
2026-06-14 00:24:05 -04:00
02ed15d6ee feat(nushell): add nu_libs autoload package
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
fix(notgraph): replace sort_by with sort_by_key (clippy)
2026-06-14 00:19:21 -04:00
50ff598684 refactor(notstrap): replace EnvInjector with SecretResolver
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Remove the EnvInjector closure and replace with a secrets_config path
that loads notsecrets.toml and uses SecretResolver for env injection.
Existing parse_env_line validation (blocked keys, null bytes) is
preserved in the injection loop.
2026-06-06 09:57:20 -04:00
1359def673 feat(notsecrets): add multi-provider secret resolution system
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Add a config-driven, strongly-typed secret/env resolution system to
notsecrets with 11 provider backends. The system supports explicit
key bindings with typed SecretRef enums and a priority chain fallback.

Tier 1 providers (implemented): Env, 1Password, dotenvx, SOPS, GSM
Tier 2 providers (stubbed): nuenv, direnv, mise, Bitwarden, Vault, dotenvy

New types: Provider, ProviderConfig, SecretRef, SecretsConfig,
SecretsError, SecretSource, EnumerableSecretSource, SecretResolver

Config lives in notsecrets.toml with typed serde deserialization.
ISP split: EnumerableSecretSource for backends that support bulk
enumeration, SecretSource for single-key lookup only.

Includes conformance test suite, 33 new tests (161 workspace total).
notstrap EnvInjector migration deferred to follow-up.
2026-06-06 09:49:05 -04:00
Joseph O'Brien
65d1a2dc2e fix(preflight): make preflight.nu script executable (#2) 2026-04-18 09:47:57 -04:00
Joseph O'Brien
da89fab58a docs: update handoff 2026-04-17 18:56:22 -04:00
Joseph O'Brien
c7c074d48b feat: add notnet crate and YubikeySource, thread Tailscale into notstrap
- New crates/notnet: TailscaleOptions, ensure_connected(), auth key chain
  (TS_AUTHKEY env → YubiKey PIV 9d → interactive prompt), install via
  apt/brew/pacman or official install.sh, peer reachability verification
- notsecrets: YubikeySource implementing IdentitySource, reads age key from
  PIV slot 9c; gated behind optional `yubikey` feature
- notstrap: Tailscale step inserted between prerequisites and clone;
  BootstrapOptions.tailscale controls override/skip; NotstrapConfig gains
  optional [tailscale] section; dotfiles_repo is now Option<String> (Tailscale
  gitea_url takes precedence); identity chain adds YubikeySource before
  Bitwarden
- Updated workspace Cargo.toml: notnet added to members and workspace deps
- Integration tests: tailscale: Some(None) to skip Tailscale in all test opts
2026-04-17 00:01:50 -04:00
Joseph O'Brien
dfdd9ca6d3 docs: add Tailscale integration design spec 2026-04-16 23:55:43 -04:00
118 changed files with 7509 additions and 367 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
.claude/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(doob:*)",
"Bash(echo:*)",
"Bash(gh:*)",
"Bash(op:*)",
"Bash(obfsck:*)",
"Bash(head:*)",
"Bash(git ls-files:*)",
"WebSearch",
"WebFetch(domain:www.apache.org)",
"Skill(todo:*)",
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" which nu)",
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" nu --version 2>&1 | head -1)",
"Bash(doob todo:*)",
"Bash(handoff-detect 2>&1; echo \"exit: $?\")",
"Bash(handoff-detect --name && handoff-detect --project)",
"Bash(rtk gain:*)",
"Bash(rtk rewrite:*)",
"Bash(rtk grep:*)",
"Bash(nu -c '\nlet result = \\(do { python3 -c \"import json; print\\(json.dumps\\({\\\\\"ok\\\\\": True}\\)\\)\" } | complete\\)\nprint $result.stdout\n')",
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -n \\\\\"^foo\\\\\" /file.md\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu)",
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -c pattern file.txt\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -i \\\\\"Hello World\\\\\" /dir/\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu; echo \"exit: $?\")"
]
}
}

25
.codex/hooks.json Normal file
View File

@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
}
]
}
]
}
}

16
.ctx/HANDOFF.md Normal file
View File

@@ -0,0 +1,16 @@
# Handoff — notfiles (2026-07-07)
**Branch:** main | **Build:** unknown | **Tests:** unknown
## Items
| ID | P | Status | Title |
|---|---|---|---|
| uncommitted-work | P1 | open | Uncommitted changes (3 files) |
## Log
- 20260606.135918: done=13 running=0 pending=0 blocked=0
- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
- 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work

View File

@@ -0,0 +1,10 @@
updated: 2026-04-17
branch: main
build: clean
tests: passing
notes: Added notnet crate with YubikeySource and threaded Tailscale into notstrap
touched_files:
- .ctx/HANDOFF.notfiles.notfiles.yaml
- Cargo.toml
- crates/notnet/src/lib.rs
- crates/notstrap/src/lib.rs

View File

@@ -1,16 +1,34 @@
project: notfiles project: notfiles
id: notfile id: notfile
updated: 2026-04-15 updated: 2026-07-07
branch: refactor/formalize-ports items:
state: - id: uncommitted-work
tests: passing doob_uuid: null
build: clean name: uncommitted-work
notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob. priority: P1
items: [] status: open
title: Uncommitted changes (3 files)
description: |-
Working tree has uncommitted changes:
.claude/worktrees/agent-a27fe5f2
.claude/settings.local.json
.superpowers/brainstorm/72758-1776397235/state/server-stopped
files:
- .claude/worktrees/agent-a27fe5f2
- .claude/settings.local.json
- .superpowers/brainstorm/72758-1776397235/state/server-stopped
completed: null
extra: []
log: log:
- date: '20260606.135918'
summary: done=13 running=0 pending=0 blocked=0
commits: []
- date: 2026-04-17
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
commits: []
- date: 2026-04-15 - date: 2026-04-15
summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
commits: commits: []
- 67f20ee
- date: 2026-04-15 - date: 2026-04-15
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
commits: []

4
.gitignore vendored
View File

@@ -2,10 +2,10 @@
.envrc .envrc
.worktrees/ .worktrees/
.claude.local.md .claude.local.md
docs/ docs/*
!docs/superpowers/
librust_out.rlib librust_out.rlib
.remember/ .remember/
!docs/superpowers/
# handoff-begin # handoff-begin

76
.notfiles-state.toml Normal file
View File

@@ -0,0 +1,76 @@
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/aliases.nu"
target = "/Users/joe/.config/nushell/autoload/aliases.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728685+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/audit.nu"
target = "/Users/joe/.config/nushell/autoload/audit.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728734+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/did-you-mean.nu"
target = "/Users/joe/.config/nushell/autoload/did-you-mean.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728773+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/functions.nu"
target = "/Users/joe/.config/nushell/autoload/functions.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728809+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nu_libs.nu"
target = "/Users/joe/.config/nushell/autoload/nu_libs.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728847+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nuenv.nu"
target = "/Users/joe/.config/nushell/autoload/nuenv.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728879+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/session-guard.nu"
target = "/Users/joe/.config/nushell/autoload/session-guard.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728915+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/settings.nu"
target = "/Users/joe/.config/nushell/autoload/settings.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728947+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/toolkit-hook.nu"
target = "/Users/joe/.config/nushell/autoload/toolkit-hook.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.728981+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/config.nu"
target = "/Users/joe/.config/nushell/config.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.729023+00:00"
[[entries]]
package = "nushell"
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/env.nu"
target = "/Users/joe/.config/nushell/env.nu"
method = "symlink"
linked_at = "2026-06-14T04:23:50.729055+00:00"

11
.sccignore Normal file
View File

@@ -0,0 +1,11 @@
.ctx
.kgx
.github
.claude
docs
examples
fuzz
target
*.md
*.json
tests/

Binary file not shown.

View File

@@ -0,0 +1,71 @@
<h2>notfiles + Tailscale — Bootstrap Architecture</h2>
<p class="subtitle">How the crates fit together for ephemeral machine setup</p>
<div class="section">
<div style="background:#1a1a2e;border-radius:12px;padding:24px;font-family:monospace;font-size:13px;line-height:2;color:#e0e0e0;">
<div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap;">
<!-- Fresh machine -->
<div style="border:2px solid #4a9eff;border-radius:8px;padding:16px;min-width:160px;background:#0d1b2a;">
<div style="color:#4a9eff;font-weight:bold;margin-bottom:8px;">New Machine</div>
<div style="color:#aaa;font-size:12px;">TS_AUTHKEY env var</div>
<div style="color:#aaa;font-size:12px;">YubiKey (PIV slot 9d)</div>
<div style="color:#aaa;font-size:12px;">→ interactive prompt</div>
</div>
<div style="color:#888;font-size:20px;margin-top:28px;"></div>
<!-- notstrap -->
<div style="border:2px solid #f0c040;border-radius:8px;padding:16px;min-width:160px;background:#1a1500;">
<div style="color:#f0c040;font-weight:bold;margin-bottom:8px;">notstrap</div>
<div style="color:#aaa;font-size:12px;">1. prereqs check</div>
<div style="color:#aaa;font-size:12px;">2. → <span style="color:#ff7eb3">notnet</span></div>
<div style="color:#aaa;font-size:12px;">3. clone Gitea</div>
<div style="color:#aaa;font-size:12px;">4. → <span style="color:#7effa0">notsecrets</span></div>
<div style="color:#aaa;font-size:12px;">5. link dotfiles</div>
<div style="color:#aaa;font-size:12px;">6. run hooks</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:0;">
<div style="display:flex;align-items:center;gap:8px;">
<div style="color:#888;font-size:20px;"></div>
<!-- notnet -->
<div style="border:2px solid #ff7eb3;border-radius:8px;padding:16px;min-width:160px;background:#1a0a14;">
<div style="color:#ff7eb3;font-weight:bold;margin-bottom:8px;">notnet <span style="color:#666;font-size:10px;">NEW</span></div>
<div style="color:#aaa;font-size:12px;">detect existing Tailscale</div>
<div style="color:#aaa;font-size:12px;">install if missing</div>
<div style="color:#aaa;font-size:12px;">resolve auth key</div>
<div style="color:#aaa;font-size:12px;">join tailnet</div>
<div style="color:#aaa;font-size:12px;">verify peer reachable</div>
</div>
<div style="color:#888;font-size:20px;"></div>
<div style="border:2px solid #7effa0;border-radius:8px;padding:12px;min-width:120px;background:#001a08;">
<div style="color:#7effa0;font-weight:bold;margin-bottom:6px;">Tailnet</div>
<div style="color:#aaa;font-size:12px;">minibox (VPS)</div>
<div style="color:#aaa;font-size:12px;">Gitea repo</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div style="color:#888;font-size:20px;"></div>
<!-- notsecrets -->
<div style="border:2px solid #7effa0;border-radius:8px;padding:16px;min-width:160px;background:#001a08;">
<div style="color:#7effa0;font-weight:bold;margin-bottom:8px;">notsecrets <span style="color:#666;font-size:10px;">+YubiKey</span></div>
<div style="color:#aaa;font-size:12px;">YubikeySource (PIV 9c)</div>
<div style="color:#aaa;font-size:12px;">BitwardenSource</div>
<div style="color:#aaa;font-size:12px;">FileSource</div>
<div style="color:#aaa;font-size:12px;">PromptSource</div>
</div>
</div>
</div>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #333;color:#888;font-size:11px;">
Already on Tailnet? notstrap detects active Tailscale → skips notnet entirely
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776399095365}

View File

@@ -0,0 +1,3 @@
{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"}
{"type":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"}
{"type":"server-stopped","reason":"idle timeout"}

View File

@@ -0,0 +1 @@
72766

110
AGENTS.md Normal file
View File

@@ -0,0 +1,110 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## What is notfiles?
A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`).
## Build & Test Commands
```bash
cargo build # build all workspace crates
cargo build -p notfiles # build just the notfiles binary
cargo test # run all tests across workspace
cargo test -p notcore # test notcore only
cargo test -p notfiles # test notfiles only
cargo test -p notsecrets # test notsecrets only
cargo test -p nothooks # test nothooks only
cargo clippy --workspace # lint all crates
cargo fmt --check # check formatting
```
## Workspace Structure
This is a Cargo workspace with 7 crates under `crates/`:
| Crate | Purpose |
| ------------ | -------------------------------------------------------------------------- |
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
| | decryption, identity management |
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
| | heatmap, cycle detection |
## Architecture
### notfiles (primary user-facing tool)
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
**Core flow for `link`:** `main``config.validate()`
`resolve_packages_filtered` (include/exclude + platform filtering) →
`collect_files` (recursive walk with ignore filtering) →
`linker::link_package` (create symlinks or copies, record in state,
return `LinkResult` with counters).
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
filesystem I/O; `Reporter` trait abstracts output. Adapters:
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
Key modules in `crates/notfiles/src/`:
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
- **package** — Discovers packages with include/exclude and platform
filtering. Recursively collects files via `IgnoreMatcher`. Typo
suggestions via `suggest_package` on not-found errors.
- **ignore** — Glob-based ignore matching using `globset`.
- **status** — Compares expected vs actual state:
linked/copied/missing/conflict/orphan. Also provides `diff_package`
for copy-method divergence detection.
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
`TerminalReporter`, `JsonReporter`.
- **ports** — `FileStore` trait definition.
### notsecrets
Multi-provider secret resolution via `SecretResolver`. Providers include
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
`sops` or `age` binaries required.
### nothooks
`HookRunner` executes `.nu` scripts via `nu <script>`. Two phases: `HookPhase::Dot` (always runs) and `HookPhase::Setup` (runs once, tracked in `.nothooks-state.toml`). `--force` flag reruns setup hooks.
### notstrap
Orchestrates in order: prereqs check → load config → clone dotfiles → age key → decrypt SOPS → link dotfiles → run hooks → final report. Config file: `notstrap.toml`.
## Configuration
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
is a "package".
Global defaults support `include` (allowlist) or `exclude` (blocklist)
for package filtering — mutually exclusive. Per-package config can
override: `method` (symlink/copy), `target` directory, additional
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
gate packages to specific OSes.
## Edition
Rust edition 2024. All hook scripts are Nushell (`.nu`) — no `.sh` scripts.
## CI / Gitea Actions
Workflows live in `.gitea/workflows/` — mirrors `.github/workflows/` for GitHub.
The `public-ready.yml` workflow checks secrets, private IPs, licenses, and tracked secrets files on every push to main.

View File

@@ -22,36 +22,64 @@ cargo fmt --check # check formatting
## Workspace Structure ## Workspace Structure
This is a Cargo workspace with 5 crates under `crates/`: This is a Cargo workspace with 7 crates under `crates/`:
| Crate | Purpose | | Crate | Purpose |
| ------------ | -------------------------------------------------------------------------------------------------------- | | ------------ | -------------------------------------------------------------------------- |
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` | | `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) | | | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption | | `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
| | decryption, identity management |
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence | | `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together | | `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, heatmap, cycle detection | | `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
| | heatmap, cycle detection |
## Architecture ## Architecture
### notfiles (primary user-facing tool) ### notfiles (primary user-facing tool)
Four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`. Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
**Core flow for `link`:** `main``resolve_packages``collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state). **Core flow for `link`:** `main``config.validate()`
`resolve_packages_filtered` (include/exclude + platform filtering) →
`collect_files` (recursive walk with ignore filtering) →
`linker::link_package` (create symlinks or copies, record in state,
return `LinkResult` with counters).
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
filesystem I/O; `Reporter` trait abstracts output. Adapters:
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
Key modules in `crates/notfiles/src/`: Key modules in `crates/notfiles/src/`:
- **linker** — Creates/removes symlinks or copies. Manages `State` (`.notfiles-state.toml`) tracking every linked file. Handles conflict detection, `--force` backups, empty-parent cleanup on unlink. - **linker** — Creates/removes symlinks or copies via `FileStore` port.
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`. Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`. linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
- **package** — Discovers packages with include/exclude and platform
filtering. Recursively collects files via `IgnoreMatcher`. Typo
suggestions via `suggest_package` on not-found errors.
- **ignore** — Glob-based ignore matching using `globset`. - **ignore** — Glob-based ignore matching using `globset`.
- **status** — Compares expected vs actual state: linked/copied/missing/conflict/orphan. - **status** — Compares expected vs actual state:
linked/copied/missing/conflict/orphan. Also provides `diff_package`
for copy-method divergence detection.
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
`TerminalReporter`, `JsonReporter`.
- **ports** — `FileStore` trait definition.
### notsecrets ### notsecrets
`AgeKeySource` trait with three implementations: `BitwardenSource` (bw CLI), `FileSource`, `PromptSource`. `resolve_age_key()` tries each in order. `install_age_key()` writes to `~/.config/sops/age/keys.txt` (mode 0600). `decrypt_sops()` shells out to `sops --decrypt`. Multi-provider secret resolution via `SecretResolver`. Providers include
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
`sops` or `age` binaries required.
### nothooks ### nothooks
@@ -63,7 +91,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
## Configuration ## Configuration
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory is a "package". Per-package config can override: `method` (symlink/copy), `target` directory, additional `ignore` patterns. `notfiles.toml` lives at the dotfiles directory root. Each subdirectory
is a "package".
Global defaults support `include` (allowlist) or `exclude` (blocklist)
for package filtering — mutually exclusive. Per-package config can
override: `method` (symlink/copy), `target` directory, additional
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
gate packages to specific OSes.
## Edition ## Edition

333
Cargo.lock generated
View File

@@ -107,6 +107,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -272,6 +278,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.0" version = "4.6.0"
@@ -342,6 +357,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -387,10 +414,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"der_derive",
"flagset",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "difflib" name = "difflib"
version = "0.4.0" version = "0.4.0"
@@ -436,6 +485,20 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@@ -466,6 +529,27 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "env_home" name = "env_home"
version = "0.1.0" version = "0.1.0"
@@ -494,6 +578,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@@ -506,6 +600,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -520,6 +620,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -570,6 +671,17 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -766,6 +878,22 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "notcore" name = "notcore"
version = "0.1.0" version = "0.1.0"
@@ -773,6 +901,7 @@ dependencies = [
"anyhow", "anyhow",
"dirs", "dirs",
"serde", "serde",
"strsim",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
] ]
@@ -785,10 +914,12 @@ dependencies = [
"assert_fs", "assert_fs",
"chrono", "chrono",
"clap", "clap",
"clap_complete",
"dirs", "dirs",
"globset", "globset",
"notcore", "notcore",
"serde", "serde",
"serde_json",
"tempfile", "tempfile",
"toml", "toml",
] ]
@@ -819,6 +950,17 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "notnet"
version = "0.1.0"
dependencies = [
"anyhow",
"rpassword",
"serde",
"thiserror 2.0.18",
"yubikey",
]
[[package]] [[package]]
name = "notsecrets" name = "notsecrets"
version = "0.1.0" version = "0.1.0"
@@ -835,12 +977,14 @@ dependencies = [
"notcore", "notcore",
"rand", "rand",
"rpassword", "rpassword",
"rsa",
"scrypt", "scrypt",
"serde",
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml",
"x25519-dalek", "x25519-dalek",
"yubikey",
"zeroize", "zeroize",
] ]
@@ -853,6 +997,7 @@ dependencies = [
"notcore", "notcore",
"notfiles", "notfiles",
"nothooks", "nothooks",
"notnet",
"notsecrets", "notsecrets",
"serde", "serde",
"toml", "toml",
@@ -871,6 +1016,7 @@ dependencies = [
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand",
"serde",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -929,6 +1075,30 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@@ -950,6 +1120,25 @@ dependencies = [
"hmac", "hmac",
] ]
[[package]]
name = "pcsc"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da"
dependencies = [
"bitflags",
"pcsc-sys",
]
[[package]]
name = "pcsc-sys"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003"
dependencies = [
"pkg-config",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -980,6 +1169,12 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -1037,6 +1232,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -1063,9 +1267,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha",
@@ -1119,6 +1323,16 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.4.0" version = "7.4.0"
@@ -1219,6 +1433,29 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "secrecy"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
dependencies = [
"zeroize",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -1281,6 +1518,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1412,6 +1660,27 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@@ -1487,6 +1756,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -1892,6 +2172,53 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"sha1",
"signature",
"spki",
"tls_codec",
]
[[package]]
name = "yubikey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d1efb43c1e3edd4cf871c8dc500d900abfa083c1f2bab10b781ea8ffcadedcb"
dependencies = [
"base16ct",
"der",
"des",
"ecdsa",
"elliptic-curve",
"hmac",
"log",
"nom",
"num-bigint-dig",
"num-integer",
"num-traits",
"p256",
"p384",
"pbkdf2",
"pcsc",
"rand_core",
"rsa",
"secrecy",
"sha1",
"sha2",
"signature",
"subtle",
"uuid",
"x509-cert",
"zeroize",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.48"

View File

@@ -1,5 +1,10 @@
[workspace.package] [workspace.package]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/89jobrien/notfiles"
homepage = "https://github.com/89jobrien/notfiles"
keywords = ["dotfiles", "symlink", "stow", "config"]
categories = ["command-line-utilities", "filesystem"]
readme = "README.md"
[workspace] [workspace]
members = [ members = [
@@ -7,6 +12,7 @@ members = [
"crates/notfiles", "crates/notfiles",
"crates/notsecrets", "crates/notsecrets",
"crates/nothooks", "crates/nothooks",
"crates/notnet",
"crates/notstrap", "crates/notstrap",
"crates/notgraph", "crates/notgraph",
"tests/integration", "tests/integration",
@@ -16,10 +22,13 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1" anyhow = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "6" dirs = "6"
globset = "0.4" globset = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
strsim = "0.11"
thiserror = "2" thiserror = "2"
toml = "0.8" toml = "0.8"
which = "7" which = "7"
@@ -30,6 +39,7 @@ notcore = { path = "crates/notcore" }
notfiles = { path = "crates/notfiles" } notfiles = { path = "crates/notfiles" }
notsecrets = { path = "crates/notsecrets" } notsecrets = { path = "crates/notsecrets" }
nothooks = { path = "crates/nothooks" } nothooks = { path = "crates/nothooks" }
notnet = { path = "crates/notnet" }
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

View File

@@ -14,6 +14,9 @@ ci:
cargo clippy --workspace -- -D warnings cargo clippy --workspace -- -D warnings
cargo nextest run --workspace cargo nextest run --workspace
install:
cargo install --path crates/notfiles --root "${HOME}/.local" --force
install-hooks: install-hooks:
#!/usr/bin/env sh #!/usr/bin/env sh
printf '#!/bin/sh\njust pre-commit\n' > .git/hooks/pre-commit printf '#!/bin/sh\njust pre-commit\n' > .git/hooks/pre-commit

View File

@@ -15,10 +15,24 @@ notfiles link
# Check status # Check status
notfiles status notfiles status
# Validate config without changes (CI-friendly)
notfiles check
# Show copy-method divergence
notfiles diff
# Move an existing file into a package
notfiles adopt git .gitconfig
# Remove symlinks # Remove symlinks
notfiles unlink notfiles unlink
# Generate shell completions
notfiles completions bash > ~/.bash_completion.d/notfiles
``` ```
All commands support `--dry-run`, `--verbose`, and `--json` flags.
On a new machine (once `notstrap` ships): On a new machine (once `notstrap` ships):
```bash ```bash
@@ -33,11 +47,13 @@ notstrap run
``` ```
notfiles/ notfiles/
├── crates/ ├── crates/
│ ├── notcore/ # shared types, config, paths, errors │ ├── notcore/ # shared types, config, paths, errors, Reporter trait
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here │ ├── notfiles/ # symlink engine (stow replacement) ← you are here
│ ├── notsecrets/ # age key retrieval + sops decrypt │ ├── notsecrets/ # multi-provider secret resolution + age crypto
│ ├── nothooks/ # hook execution engine │ ├── nothooks/ # hook execution engine
── notstrap/ # new-machine bootstrap orchestrator ── notnet/ # network utilities (Tailscale, Yubikey)
│ ├── notstrap/ # new-machine bootstrap orchestrator
│ └── notgraph/ # Rust dependency graph visualizer
├── notfiles.toml # symlink package config ├── notfiles.toml # symlink package config
└── notstrap.toml # bootstrap hook/phase config └── notstrap.toml # bootstrap hook/phase config
``` ```
@@ -46,11 +62,13 @@ notfiles/
| Crate | Type | Does | | Crate | Type | Does |
|-------|------|------| |-------|------|------|
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates | | `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state | | `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets | | `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks | | `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
| `notnet` | lib | Tailscale integration, Yubikey identity source |
| `notstrap` | bin | Orchestrate everything on a fresh machine | | `notstrap` | bin | Orchestrate everything on a fresh machine |
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
### Dependency graph ### Dependency graph
@@ -60,6 +78,7 @@ notstrap
│ └── notcore │ └── notcore
├── notsecrets ├── notsecrets
│ └── notcore │ └── notcore
├── notnet
└── nothooks └── nothooks
└── notcore └── notcore
``` ```
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
``` ```
notfiles link notfiles link
├─ resolve_packages() discover subdirs or validate requested names ├─ config.validate() check include/exclude mutual exclusion
├─ collect_files() recursive walk, apply ignore patterns (globset) ├─ resolve_packages_filtered() include/exclude + platform filtering
├─ collect_files() recursive walk, apply ignore patterns
├─ conflict_check() existing file? symlink to wrong target? ├─ conflict_check() existing file? symlink to wrong target?
└─ linker::link_package() create symlinks (or copies), write state └─ linker::link_package() create symlinks (or copies), write state,
return LinkResult with counters
``` ```
### State file ### State file
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
## Secrets Bootstrap Detail ## Secrets Bootstrap Detail
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order: `notsecrets` implements a `SecretResolver` with pluggable provider sources:
``` ```
AgeKeySource SecretResolver
├── BitwardenSource bw unlock → session token → bw get item age-key ├── EnvSource read from environment variables
├── FileSource read from --key-file path (USB, etc.) ├── OpSource 1Password CLI (op read)
── PromptSource read from stdin (paste) ── BitwardenSource bw CLI
├── FileSource read from file path
├── DotenvxSource dotenvx encrypted .env files
├── SopsSource SOPS-encrypted files (native age decryption)
└── ... (extensible via SecretSource trait)
``` ```
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. Age encryption/decryption is handled natively (no external `age` or `sops`
binaries). Supports x25519, SSH ed25519/RSA, and scrypt identities.
--- ---
@@ -190,10 +217,15 @@ Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and
[defaults] [defaults]
method = "symlink" method = "symlink"
target = "~" target = "~"
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
# exclude = ["scratch"] # or blocklist (mutually exclusive)
[packages.secrets] [packages.secrets]
method = "copy" # copy instead of symlink for sensitive files method = "copy" # copy instead of symlink for sensitive files
[packages.nixos]
platforms = ["linux"] # only link on Linux
[packages.work] [packages.work]
target = "~/work" # different target dir target = "~/work" # different target dir
ignore = ["*.local"] ignore = ["*.local"]

BIN
crates/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -3,10 +3,16 @@ name = "notcore"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license.workspace = true license.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Shared types and config for the notfiles dotfiles manager"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
strsim = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@@ -18,6 +18,14 @@ pub struct Defaults {
pub target: String, pub target: String,
#[serde(default = "default_ignore")] #[serde(default = "default_ignore")]
pub ignore: Vec<String>, pub ignore: Vec<String>,
/// If set, only these package names are discovered.
/// Mutually exclusive with `exclude`.
#[serde(default)]
pub include: Vec<String>,
/// If set, these package names are skipped during discovery.
/// Mutually exclusive with `include`.
#[serde(default)]
pub exclude: Vec<String>,
} }
impl Default for Defaults { impl Default for Defaults {
@@ -25,6 +33,8 @@ impl Default for Defaults {
Self { Self {
target: default_target(), target: default_target(),
ignore: default_ignore(), ignore: default_ignore(),
include: Vec::new(),
exclude: Vec::new(),
} }
} }
} }
@@ -52,6 +62,10 @@ pub struct PackageConfig {
pub target: Option<String>, pub target: Option<String>,
#[serde(default)] #[serde(default)]
pub ignore: Vec<String>, pub ignore: Vec<String>,
/// If non-empty, this package is only linked on these platforms.
/// Valid values: "macos", "linux".
#[serde(default)]
pub platforms: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -100,6 +114,50 @@ impl Config {
.unwrap_or(&self.defaults.target) .unwrap_or(&self.defaults.target)
} }
/// Returns the include list if non-empty, or None (meaning all).
pub fn included_packages(&self) -> Option<Vec<&str>> {
if self.defaults.include.is_empty() {
None
} else {
let mut v: Vec<&str> = self.defaults.include.iter().map(|s| s.as_str()).collect();
v.sort();
Some(v)
}
}
/// Returns true if this package name appears in the exclude list.
pub fn is_package_excluded(&self, name: &str) -> bool {
self.defaults.exclude.iter().any(|e| e == name)
}
/// Validate config invariants. Returns Err if include and exclude
/// are both non-empty.
pub fn validate(&self) -> Result<(), NotfilesError> {
if !self.defaults.include.is_empty() && !self.defaults.exclude.is_empty() {
return Err(NotfilesError::Validation(
"include and exclude are mutually exclusive".into(),
));
}
Ok(())
}
/// Returns true if the package should be linked on the current OS.
/// Empty platforms list means "all platforms".
pub fn is_package_for_current_platform(&self, package: &str) -> bool {
let platforms = match self.packages.get(package) {
Some(pkg) if !pkg.platforms.is_empty() => &pkg.platforms,
_ => return true,
};
let current = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
return false;
};
platforms.iter().any(|p| p == current)
}
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> { 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(); let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
if let Some(pkg) = self.packages.get(package) { if let Some(pkg) = self.packages.get(package) {
@@ -111,6 +169,23 @@ impl Config {
} }
} }
/// Suggest the closest package name if the user made a typo.
/// Returns None if no close match (distance > 2).
pub fn suggest_package<'a>(name: &str, available: &[&'a str]) -> Option<&'a str> {
available
.iter()
.filter_map(|candidate| {
let dist = strsim::damerau_levenshtein(name, candidate);
if dist <= 2 {
Some((*candidate, dist))
} else {
None
}
})
.min_by_key(|(_, d)| *d)
.map(|(name, _)| name)
}
pub fn starter_toml() -> &'static str { pub fn starter_toml() -> &'static str {
r#"[defaults] r#"[defaults]
target = "~" target = "~"
@@ -138,6 +213,92 @@ mod tests {
assert_eq!(config.target_for("anything"), "~"); assert_eq!(config.target_for("anything"), "~");
} }
#[test]
fn test_include_filter_restricts_packages() {
let toml_str = r#"
[defaults]
target = "~"
ignore = [".git"]
include = ["git", "zsh", "nushell"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(
config.included_packages(),
Some(vec!["git", "nushell", "zsh"]),
);
}
#[test]
fn test_exclude_filter_blocks_packages() {
let toml_str = r#"
[defaults]
target = "~"
ignore = [".git"]
exclude = ["scripts", "docs", "tests"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.is_package_excluded("scripts"));
assert!(!config.is_package_excluded("zsh"));
}
#[test]
fn test_include_and_exclude_mutual_exclusion() {
let toml_str = r#"
[defaults]
target = "~"
include = ["git"]
exclude = ["scripts"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_no_include_no_exclude_returns_none() {
let config = Config::default();
assert_eq!(config.included_packages(), None);
assert!(!config.is_package_excluded("anything"));
assert!(config.validate().is_ok());
}
#[test]
fn test_platform_filter_linux_only() {
let toml_str = r#"
[defaults]
target = "~"
[packages.nixos]
platforms = ["linux"]
[packages.git]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
// On macOS this should be false, on Linux true
if cfg!(target_os = "macos") {
assert!(!config.is_package_for_current_platform("nixos"));
} else if cfg!(target_os = "linux") {
assert!(config.is_package_for_current_platform("nixos"));
}
// git has no platform filter — always matches
assert!(config.is_package_for_current_platform("git"));
// unknown package — no config entry, always matches
assert!(config.is_package_for_current_platform("zsh"));
}
#[test]
fn test_platform_filter_macos_only() {
let toml_str = r#"
[packages.vscode]
platforms = ["macos"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
if cfg!(target_os = "macos") {
assert!(config.is_package_for_current_platform("vscode"));
} else {
assert!(!config.is_package_for_current_platform("vscode"));
}
}
#[test] #[test]
fn test_parse_config() { fn test_parse_config() {
let toml_str = r#" let toml_str = r#"

View File

@@ -17,6 +17,12 @@ pub enum NotfilesError {
#[error("state file error: {0}")] #[error("state file error: {0}")]
State(String), State(String),
#[error("glob error: {0}")]
Glob(String),
#[error("validation error: {0}")]
Validation(String),
#[error("{0}")] #[error("{0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),

View File

@@ -1,9 +1,17 @@
//! Core types and configuration for the notfiles dotfiles manager.
//!
//! This crate provides shared types used across the notfiles workspace:
//! configuration parsing, error types, path utilities, and the reporter
//! trait for output abstraction.
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod paths; pub mod paths;
pub mod reporter;
pub mod types; pub mod types;
pub use config::{Config, Defaults, Method, PackageConfig}; pub use config::{Config, Defaults, Method, PackageConfig, suggest_package};
pub use error::NotfilesError; pub use error::NotfilesError;
pub use paths::{dotfiles_dir, expand_tilde}; pub use paths::{dotfiles_dir, expand_tilde};
pub use reporter::{LinkEvent, Reporter, SilentReporter};
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus}; pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};

View File

@@ -0,0 +1,46 @@
use std::path::Path;
/// Events emitted during link/unlink/status operations.
#[derive(Debug, Clone)]
pub enum LinkEvent<'a> {
Link {
source: &'a str,
target: &'a Path,
},
Copy {
source: &'a str,
target: &'a Path,
},
Skip {
source: &'a str,
reason: &'a str,
},
Backup {
from: &'a Path,
to: &'a Path,
},
Remove {
target: &'a Path,
},
CreateDir {
path: &'a Path,
},
DryRun {
action: &'a str,
source: &'a str,
target: &'a Path,
},
}
/// Port for reporting link/unlink progress. Library code calls this
/// instead of println!.
pub trait Reporter {
fn report(&self, event: &LinkEvent<'_>);
}
/// No-op reporter for when output is unwanted.
pub struct SilentReporter;
impl Reporter for SilentReporter {
fn report(&self, _event: &LinkEvent<'_>) {}
}

BIN
crates/notfiles/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -3,6 +3,10 @@ name = "notfiles"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license.workspace = true license.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow" description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
[[bin]] [[bin]]
@@ -16,9 +20,11 @@ path = "src/lib.rs"
[dependencies] [dependencies]
notcore = { workspace = true } notcore = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
clap_complete = { workspace = true }
globset = { workspace = true } globset = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }

View File

@@ -0,0 +1,293 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::ports::FileStore;
/// In-memory file system for testing. Not thread-safe.
pub struct InMemoryFileStore {
files: RefCell<HashMap<PathBuf, Vec<u8>>>,
dirs: RefCell<HashSet<PathBuf>>,
symlinks: RefCell<HashMap<PathBuf, PathBuf>>,
}
impl InMemoryFileStore {
pub fn new() -> Self {
Self {
files: RefCell::new(HashMap::new()),
dirs: RefCell::new(HashSet::new()),
symlinks: RefCell::new(HashMap::new()),
}
}
pub fn add_file(&self, path: impl AsRef<Path>, content: &[u8]) {
let path = path.as_ref().to_path_buf();
// Ensure parent dirs exist
if let Some(parent) = path.parent() {
self.ensure_parents(parent);
}
self.files.borrow_mut().insert(path, content.to_vec());
}
pub fn add_dir(&self, path: impl AsRef<Path>) {
self.ensure_parents(path.as_ref());
}
pub fn has_symlink(&self, link: &Path) -> bool {
self.symlinks.borrow().contains_key(link)
}
pub fn symlink_target(&self, link: &Path) -> Option<PathBuf> {
self.symlinks.borrow().get(link).cloned()
}
fn ensure_parents(&self, path: &Path) {
let mut current = path.to_path_buf();
let mut dirs = self.dirs.borrow_mut();
loop {
if !dirs.insert(current.clone()) {
break;
}
match current.parent() {
Some(p) if p != current => current = p.to_path_buf(),
_ => break,
}
}
}
}
impl Default for InMemoryFileStore {
fn default() -> Self {
Self::new()
}
}
impl FileStore for InMemoryFileStore {
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
// Follow symlinks
if let Some(target) = self.symlinks.borrow().get(path) {
return self.read(target);
}
self.files.borrow().get(path).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
})
}
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
let bytes = self.read(path)?;
String::from_utf8(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
self.ensure_parents(parent);
}
self.files
.borrow_mut()
.insert(path.to_path_buf(), contents.to_vec());
Ok(())
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error> {
let content = self.files.borrow_mut().remove(from);
if let Some(content) = content {
if let Some(parent) = to.parent() {
self.ensure_parents(parent);
}
self.files.borrow_mut().insert(to.to_path_buf(), content);
return Ok(());
}
if self.symlinks.borrow_mut().remove(from).is_some() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
from.display().to_string(),
))
}
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error> {
if self.files.borrow_mut().remove(path).is_some() {
return Ok(());
}
if self.symlinks.borrow_mut().remove(path).is_some() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
path.display().to_string(),
))
}
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
let path = path.to_path_buf();
self.files.borrow_mut().retain(|k, _| !k.starts_with(&path));
self.dirs.borrow_mut().retain(|k| !k.starts_with(&path));
self.symlinks
.borrow_mut()
.retain(|k, _| !k.starts_with(&path));
Ok(())
}
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> {
self.dirs.borrow_mut().remove(path);
Ok(())
}
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
self.symlinks.borrow().get(path).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
})
}
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
// We can't construct real Metadata, but we can indicate existence
// by returning NotFound when the path doesn't exist
if self.files.borrow().contains_key(path)
|| self.dirs.borrow().contains(path)
|| self.symlinks.borrow().contains_key(path)
{
// Return metadata of a real temp file as a stand-in
// This is a known limitation of the in-memory store
Err(std::io::Error::other(
"InMemoryFileStore: metadata not available",
))
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
path.display().to_string(),
))
}
}
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
self.symlink_metadata(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
self.ensure_parents(path);
Ok(())
}
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
let path = path.to_path_buf();
let mut children = Vec::new();
for key in self.files.borrow().keys() {
if key.parent() == Some(&path) {
children.push(key.clone());
}
}
for dir in self.dirs.borrow().iter() {
if dir.parent() == Some(&path) && dir != &path && !children.contains(dir) {
children.push(dir.clone());
}
}
for key in self.symlinks.borrow().keys() {
if key.parent() == Some(&path) && !children.contains(key) {
children.push(key.clone());
}
}
Ok(children)
}
#[cfg(unix)]
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
if let Some(parent) = link.parent() {
self.ensure_parents(parent);
}
self.symlinks
.borrow_mut()
.insert(link.to_path_buf(), target.to_path_buf());
Ok(())
}
fn exists(&self, path: &Path) -> bool {
self.files.borrow().contains_key(path)
|| self.dirs.borrow().contains(path)
|| self.symlinks.borrow().contains_key(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.dirs.borrow().contains(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_file_creates_parents() {
let fs = InMemoryFileStore::new();
fs.add_file("/a/b/c.txt", b"hello");
assert!(fs.is_dir(Path::new("/a")));
assert!(fs.is_dir(Path::new("/a/b")));
assert!(fs.exists(Path::new("/a/b/c.txt")));
}
#[test]
fn test_read_write_roundtrip() {
let fs = InMemoryFileStore::new();
fs.write(Path::new("/tmp/f.txt"), b"data").unwrap();
assert_eq!(fs.read(Path::new("/tmp/f.txt")).unwrap(), b"data");
assert_eq!(fs.read_to_string(Path::new("/tmp/f.txt")).unwrap(), "data");
}
#[test]
fn test_symlink_roundtrip() {
let fs = InMemoryFileStore::new();
fs.add_file("/src/config", b"content");
fs.symlink(Path::new("/src/config"), Path::new("/home/config"))
.unwrap();
assert!(fs.has_symlink(Path::new("/home/config")));
assert_eq!(
fs.read_link(Path::new("/home/config")).unwrap(),
PathBuf::from("/src/config")
);
// Reading through symlink
assert_eq!(fs.read(Path::new("/home/config")).unwrap(), b"content");
}
#[test]
fn test_rename_file() {
let fs = InMemoryFileStore::new();
fs.add_file("/a.txt", b"hello");
fs.rename(Path::new("/a.txt"), Path::new("/b.txt")).unwrap();
assert!(!fs.exists(Path::new("/a.txt")));
assert_eq!(fs.read(Path::new("/b.txt")).unwrap(), b"hello");
}
#[test]
fn test_remove_dir_all() {
let fs = InMemoryFileStore::new();
fs.add_file("/dir/a.txt", b"a");
fs.add_file("/dir/sub/b.txt", b"b");
fs.remove_dir_all(Path::new("/dir")).unwrap();
assert!(!fs.exists(Path::new("/dir/a.txt")));
assert!(!fs.exists(Path::new("/dir/sub/b.txt")));
assert!(!fs.is_dir(Path::new("/dir")));
}
#[test]
fn test_read_dir_lists_children() {
let fs = InMemoryFileStore::new();
fs.add_file("/dir/a.txt", b"a");
fs.add_file("/dir/b.txt", b"b");
fs.add_dir("/dir/sub");
let mut children = fs.read_dir(Path::new("/dir")).unwrap();
children.sort();
assert_eq!(
children,
vec![
PathBuf::from("/dir/a.txt"),
PathBuf::from("/dir/b.txt"),
PathBuf::from("/dir/sub"),
]
);
}
}

View File

@@ -1,3 +1,7 @@
pub mod fs; pub mod fs;
pub mod memory;
pub mod reporter;
pub use fs::FileStoreImpl; pub use fs::FileStoreImpl;
pub use memory::InMemoryFileStore;
pub use reporter::{JsonReporter, TerminalReporter};

View File

@@ -0,0 +1,81 @@
use serde_json::json;
use notcore::reporter::{LinkEvent, Reporter};
/// Terminal reporter with ANSI color output.
pub struct TerminalReporter;
/// JSON reporter: one JSON object per line to stdout.
pub struct JsonReporter;
impl Reporter for TerminalReporter {
fn report(&self, event: &LinkEvent<'_>) {
match event {
LinkEvent::Link { source, target } => {
println!(" \x1b[32mlink\x1b[0m {source} -> {}", target.display());
}
LinkEvent::Copy { source, target } => {
println!(" \x1b[32mcopy\x1b[0m {source} -> {}", target.display());
}
LinkEvent::Skip { source, reason } => {
println!(" \x1b[90mskip\x1b[0m {source} ({reason})");
}
LinkEvent::Backup { from, to } => {
println!(
" \x1b[33mbackup\x1b[0m {} -> {}",
from.display(),
to.display()
);
}
LinkEvent::Remove { target } => {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
LinkEvent::CreateDir { path } => {
println!(" \x1b[90mcreate dir\x1b[0m {}", path.display());
}
LinkEvent::DryRun {
action,
source,
target,
} => {
println!(
" \x1b[36mwould {action}\x1b[0m {source} -> {}",
target.display()
);
}
}
}
}
impl Reporter for JsonReporter {
fn report(&self, event: &LinkEvent<'_>) {
let obj = match event {
LinkEvent::Link { source, target } => {
json!({"event": "link", "source": source, "target": target.to_string_lossy()})
}
LinkEvent::Copy { source, target } => {
json!({"event": "copy", "source": source, "target": target.to_string_lossy()})
}
LinkEvent::Skip { source, reason } => {
json!({"event": "skip", "source": source, "reason": reason})
}
LinkEvent::Backup { from, to } => {
json!({"event": "backup", "from": from.to_string_lossy(), "to": to.to_string_lossy()})
}
LinkEvent::Remove { target } => {
json!({"event": "remove", "target": target.to_string_lossy()})
}
LinkEvent::CreateDir { path } => {
json!({"event": "create_dir", "path": path.to_string_lossy()})
}
LinkEvent::DryRun {
action,
source,
target,
} => {
json!({"event": "dry_run", "action": action, "source": source, "target": target.to_string_lossy()})
}
};
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
}
}

View File

@@ -16,10 +16,39 @@ pub struct Cli {
#[arg(long, short, global = true)] #[arg(long, short, global = true)]
pub verbose: bool, pub verbose: bool,
/// Output in JSON format
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)] #[command(subcommand)]
pub command: Command, pub command: Command,
} }
// TODO(install): `notfiles` is not yet installed on PATH. Add `just install` recipe and
// `mise task` so `nf` / `notfiles` is available without `cargo run`. See Justfile.
// TODO(bootstrap): Wire `notstrap` into the CLI as `notfiles bootstrap`. Should clone the
// repo, resolve age key (Bitwarden/YubiKey/prompt), decrypt SOPS secrets, link packages,
// and run post-hooks. The notstrap crate already models this — needs a CLI entry point.
// TODO(migrate): Add `notfiles migrate <dir>` that takes a stow/chezmoi repo (from `detect`)
// and generates a notfiles.toml for it, then bulk-adopts all packages. Should call
// `detect::detect()`, let the user pick a source, and run `adopt` for each package.
// TODO(which): Add `notfiles which <path>` — reverse lookup from a target path (e.g.
// ~/.gitconfig) to its source package and file. Walk state entries and check read_link.
// TODO(doctor): Add `notfiles doctor` — holistic drift detection replacing drift-check.sh.
// Checks: broken symlinks, unmanaged dotfiles in $HOME, dirty git state, conflicts,
// packages in dir not listed in notfiles.toml. Should print a summary like `just doctor`.
// TODO(discover): Add `[defaults] discover = true` mode to notfiles.toml so all top-level
// dirs are treated as packages automatically, without maintaining an explicit `include`
// list. Opt-in via config, not the default (explicit is safer).
// TODO(edit): Add `notfiles edit <managed-path>` — resolve the source file for a managed
// symlink and open it in $EDITOR. Quality-of-life shortcut for day-to-day editing.
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum Command { pub enum Command {
/// Create a starter notfiles.toml /// Create a starter notfiles.toml
@@ -50,4 +79,31 @@ pub enum Command {
/// Specific packages to check (default: all) /// Specific packages to check (default: all)
packages: Vec<String>, packages: Vec<String>,
}, },
/// Validate config without making changes (CI-friendly)
Check,
/// Generate shell completions
Completions {
/// Shell to generate for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
/// Show differences between source and target for copy-method packages
Diff {
/// Specific packages to diff (default: all copy-method packages)
packages: Vec<String>,
},
/// Auto-detect existing dotfile managers (stow, chezmoi, etc.)
Detect,
/// Move existing files into a package and replace with symlinks
Adopt {
/// Package to adopt files into
package: String,
/// File paths (relative to target dir) to adopt
files: Vec<String>,
},
} }

View File

@@ -0,0 +1,300 @@
//! Auto-detection of existing dotfile managers.
//!
//! Scans common dotfile repo locations and identifies what's managing them.
use std::path::{Path, PathBuf};
use serde_json::json;
/// A dotfile manager detected on disk.
#[derive(Debug)]
pub struct DetectedManager {
pub kind: ManagerKind,
pub root: PathBuf,
pub packages: Vec<DetectedPackage>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ManagerKind {
/// GNU Stow — packages are top-level dirs, files mirror $HOME layout.
Stow,
/// chezmoi — uses `.chezmoi.*` config and `dot_` prefix convention.
Chezmoi,
/// Notfiles — has a `notfiles.toml` config file.
Notfiles,
/// Unknown structure — looks like a dotfile repo but manager unclear.
Unknown,
}
impl std::fmt::Display for ManagerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ManagerKind::Stow => write!(f, "stow"),
ManagerKind::Chezmoi => write!(f, "chezmoi"),
ManagerKind::Notfiles => write!(f, "notfiles"),
ManagerKind::Unknown => write!(f, "unknown"),
}
}
}
/// A package (top-level directory) inside a detected dotfile repo.
#[derive(Debug)]
pub struct DetectedPackage {
pub name: String,
/// Files relative to the package directory (mirrors home layout).
pub files: Vec<PathBuf>,
}
/// Locations to probe relative to `$HOME`.
const CANDIDATE_DIRS: &[&str] = &[
"dotfiles",
".dotfiles",
"dot",
".dot",
"dots",
".dots",
"config/dotfiles",
".config/dotfiles",
];
/// Detect all dotfile managers reachable from `home`.
pub fn detect(home: &Path) -> Vec<DetectedManager> {
let mut found = Vec::new();
for rel in CANDIDATE_DIRS {
let dir = home.join(rel);
if dir.is_dir()
&& let Some(m) = probe(&dir)
{
found.push(m);
}
}
// chezmoi source dir at its default non-standard location
let chezmoi_src = home.join(".local/share/chezmoi");
if chezmoi_src.is_dir() && !found.iter().any(|m| m.root == chezmoi_src) {
found.push(DetectedManager {
kind: ManagerKind::Chezmoi,
packages: enumerate_chezmoi_packages(&chezmoi_src),
root: chezmoi_src,
});
}
found
}
fn probe(dir: &Path) -> Option<DetectedManager> {
let kind = classify(dir);
let packages = match kind {
ManagerKind::Stow | ManagerKind::Unknown => enumerate_stow_packages(dir),
ManagerKind::Chezmoi => enumerate_chezmoi_packages(dir),
ManagerKind::Notfiles => enumerate_notfiles_packages(dir),
};
Some(DetectedManager {
kind,
root: dir.to_path_buf(),
packages,
})
}
fn classify(dir: &Path) -> ManagerKind {
if dir.join("notfiles.toml").exists() {
return ManagerKind::Notfiles;
}
if dir.join(".chezmoi.toml.tmpl").exists()
|| dir.join(".chezmoi.yaml.tmpl").exists()
|| dir.join(".chezmoi.json.tmpl").exists()
|| dir.join(".chezmoi.toml").exists()
|| dir.join(".chezmoi.yaml").exists()
{
return ManagerKind::Chezmoi;
}
if dir.join(".stow-local-ignore").exists()
|| dir.join(".stowrc").exists()
|| has_stow_structure(dir)
{
return ManagerKind::Stow;
}
ManagerKind::Unknown
}
/// Heuristic: looks like stow if ≥2 top-level subdirs contain dot-dirs or
/// known config paths (`.config/`, `.local/`, `Library/`, etc.).
fn has_stow_structure(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
let mut stow_like = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with('.') || matches!(s.as_ref(), "target" | "src") {
continue;
}
if package_looks_like_stow(&path) {
stow_like += 1;
if stow_like >= 2 {
return true;
}
}
}
false
}
fn package_looks_like_stow(pkg_dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(pkg_dir) else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with('.') || s == "Library" {
return true;
}
}
false
}
fn enumerate_stow_packages(root: &Path) -> Vec<DetectedPackage> {
let Ok(entries) = std::fs::read_dir(root) else {
return vec![];
};
let mut pkgs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || matches!(name.as_str(), "target" | "src" | "docs" | "tests") {
continue;
}
let files = walk_files(&path, &path);
pkgs.push(DetectedPackage { name, files });
}
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
pkgs
}
fn enumerate_chezmoi_packages(root: &Path) -> Vec<DetectedPackage> {
let files = walk_files(root, root);
if files.is_empty() {
vec![]
} else {
vec![DetectedPackage {
name: "(source)".into(),
files,
}]
}
}
fn enumerate_notfiles_packages(root: &Path) -> Vec<DetectedPackage> {
let toml_path = root.join("notfiles.toml");
if let Ok(content) = std::fs::read_to_string(&toml_path)
&& let Some(names) = parse_include_list(&content)
{
return names
.into_iter()
.map(|name| {
let pkg_dir = root.join(&name);
let files = walk_files(&pkg_dir, &pkg_dir);
DetectedPackage { name, files }
})
.collect();
}
enumerate_stow_packages(root)
}
/// Minimal parse of `include = ["a", "b"]` from TOML.
fn parse_include_list(toml: &str) -> Option<Vec<String>> {
let line = toml
.lines()
.find(|l| l.trim_start().starts_with("include"))?;
let bracket_start = line.find('[')?;
let bracket_end = line.find(']')?;
let inner = &line[bracket_start + 1..bracket_end];
let names: Vec<String> = inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect();
if names.is_empty() { None } else { Some(names) }
}
/// Recursively collect file paths relative to `base`.
fn walk_files(dir: &Path, base: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return files;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if matches!(name.as_str(), ".git" | ".DS_Store") {
continue;
}
if path.is_dir() {
files.extend(walk_files(&path, base));
} else if path.is_file()
&& let Ok(rel) = path.strip_prefix(base)
{
files.push(rel.to_path_buf());
}
}
files
}
// ── Output ────────────────────────────────────────────────────────────────────
pub fn print_detected(managers: &[DetectedManager]) {
if managers.is_empty() {
println!("No dotfile managers detected.");
return;
}
println!("Detected dotfile managers:\n");
for m in managers {
let pkg_count = m.packages.len();
let file_count: usize = m.packages.iter().map(|p| p.files.len()).sum();
let pkg_names: Vec<&str> = m.packages.iter().map(|p| p.name.as_str()).collect();
println!(" \x1b[1m{}\x1b[0m {}", m.kind, m.root.display());
println!(
" {} package{}, {} file{}",
pkg_count,
if pkg_count == 1 { "" } else { "s" },
file_count,
if file_count == 1 { "" } else { "s" },
);
if !pkg_names.is_empty() {
println!(" packages: {}", pkg_names.join(", "));
}
println!();
}
}
pub fn print_detected_json(managers: &[DetectedManager]) {
let items: Vec<_> = managers
.iter()
.map(|m| {
json!({
"manager": m.kind.to_string(),
"root": m.root.to_string_lossy(),
"packages": m.packages.iter().map(|p| {
json!({
"name": p.name,
"files": p.files.iter()
.map(|f| f.to_string_lossy().to_string())
.collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&items).unwrap_or_default()
);
}

View File

@@ -15,7 +15,7 @@ impl IgnoreMatcher {
let glob = Glob::new(pattern) let glob = Glob::new(pattern)
.or_else(|_| Glob::new(&format!("**/{pattern}"))) .or_else(|_| Glob::new(&format!("**/{pattern}")))
.map_err(|e| { .map_err(|e| {
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")) NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
})?; })?;
builder.add(glob); builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc. // Also add a recursive variant so "foo" matches "a/foo" etc.
@@ -28,7 +28,7 @@ impl IgnoreMatcher {
} }
let globset = builder let globset = builder
.build() .build()
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?; .map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
Ok(Self { globset }) Ok(Self { globset })
} }

View File

@@ -1,49 +1,107 @@
//! A modern dotfiles manager — pure Rust alternative to GNU Stow.
//!
//! Symlinks (or copies) files from organized "package" directories
//! into a target location (typically `~`). Supports include/exclude
//! filtering, platform gates, and multiple output formats.
//!
//! # Quick start
//!
//! ```no_run
//! use notfiles::{link, LinkOptions};
//! use std::path::Path;
//!
//! let opts = LinkOptions {
//! force: false,
//! no_backup: false,
//! dry_run: false,
//! verbose: true,
//! };
//! let state = link(Path::new("/home/user/dotfiles"), &[], &opts)
//! .expect("link failed");
//! ```
pub mod adapters; pub mod adapters;
pub mod cli; pub mod cli;
pub mod detect;
pub mod ignore; pub mod ignore;
pub mod linker; pub mod linker;
pub mod package; pub mod package;
pub mod ports; pub mod ports;
pub mod status; pub mod status;
use anyhow::Result;
use std::path::Path; use std::path::Path;
use notcore::NotfilesError;
use notcore::reporter::Reporter;
pub use adapters::FileStoreImpl; pub use adapters::FileStoreImpl;
pub use linker::{LinkOptions, State}; pub use linker::{LinkOptions, LinkResult, State};
pub use package::{resolve_packages, resolve_packages_with_store}; pub use package::{
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
resolve_packages_with_store,
};
pub use ports::FileStore; pub use ports::FileStore;
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> { /// Link all (or specified) packages using the real filesystem and
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) /// terminal output.
pub fn link(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State, NotfilesError> {
let reporter = adapters::TerminalReporter;
link_with_store(
dotfiles_dir,
packages,
opts,
&adapters::FileStoreImpl,
&reporter,
)
} }
/// Link packages with injectable filesystem and reporter (for testing).
pub fn link_with_store( pub fn link_with_store(
dotfiles_dir: &Path, dotfiles_dir: &Path,
packages: &[String], packages: &[String],
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<State> { reporter: &dyn Reporter,
) -> Result<State, NotfilesError> {
let config = notcore::Config::load(dotfiles_dir)?; let config = notcore::Config::load(dotfiles_dir)?;
config.validate()?;
let mut state = linker::State::load(dotfiles_dir, fs)?; let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = resolve_packages_with_store(dotfiles_dir, packages, fs)?; let pkgs = package::resolve_packages_filtered_with_store(dotfiles_dir, packages, &config, fs)?;
for pkg in &pkgs { for pkg in &pkgs {
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?; linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs, reporter)?;
} }
state.save(dotfiles_dir, fs)?; state.save(dotfiles_dir, fs)?;
Ok(state) Ok(state)
} }
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> { /// Remove managed symlinks/copies for all (or specified) packages.
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) pub fn unlink(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<(), NotfilesError> {
let reporter = adapters::TerminalReporter;
unlink_with_store(
dotfiles_dir,
packages,
opts,
&adapters::FileStoreImpl,
&reporter,
)
} }
/// Unlink packages with injectable filesystem and reporter.
pub fn unlink_with_store( pub fn unlink_with_store(
dotfiles_dir: &Path, dotfiles_dir: &Path,
packages: &[String], packages: &[String],
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<()> { reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
let mut state = linker::State::load(dotfiles_dir, fs)?; let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() { let pkgs = if packages.is_empty() {
state state
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
packages.to_vec() packages.to_vec()
}; };
for pkg in &pkgs { for pkg in &pkgs {
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs)?; linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs, reporter)?;
} }
state.save(dotfiles_dir, fs)?; state.save(dotfiles_dir, fs)?;
Ok(()) Ok(())

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::package::collect_files_with_store; use crate::package::collect_files_with_store;
use crate::ports::FileStore; use crate::ports::FileStore;
use notcore::reporter::{LinkEvent, Reporter};
use notcore::{Config, Method, NotfilesError, expand_tilde}; use notcore::{Config, Method, NotfilesError, expand_tilde};
const STATE_FILE: &str = ".notfiles-state.toml"; const STATE_FILE: &str = ".notfiles-state.toml";
@@ -81,6 +82,15 @@ pub struct LinkOptions {
pub verbose: bool, pub verbose: bool,
} }
#[derive(Debug, Default)]
pub struct LinkResult {
pub package: String,
pub linked: usize,
pub copied: usize,
pub skipped: usize,
pub backed_up: usize,
}
pub fn link_package( pub fn link_package(
dotfiles_dir: &Path, dotfiles_dir: &Path,
config: &Config, config: &Config,
@@ -88,17 +98,23 @@ pub fn link_package(
package: &str, package: &str,
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<(), NotfilesError> { reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package); let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package); let method = config.method_for(package);
let target_base = expand_tilde(config.target_for(package))?; let target_base = expand_tilde(config.target_for(package))?;
let files = collect_files_with_store(&package_dir, config, package, fs)?; let files = collect_files_with_store(&package_dir, config, package, fs)?;
let mut result = LinkResult {
package: package.to_string(),
..Default::default()
};
if files.is_empty() { if files.is_empty() {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!(" {package}: no files to link"); source: package,
} reason: "no files to link",
return Ok(()); });
return Ok(result);
} }
for relative in &files { for relative in &files {
@@ -108,14 +124,17 @@ pub fn link_package(
// Check if already correctly linked / copied // Check if already correctly linked / copied
if is_already_linked(&source, &target, method, fs) { if is_already_linked(&source, &target, method, fs) {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)"); source: &source_display,
} reason: "already linked",
});
result.skipped += 1;
continue; continue;
} }
// Helper: save partial state then return an error. // Helper: save partial state then return an error.
let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> { let save_and_return =
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
if !opts.dry_run { if !opts.dry_run {
let _ = state.save(dotfiles_dir, fs); let _ = state.save(dotfiles_dir, fs);
} }
@@ -139,22 +158,20 @@ pub fn link_package(
if !opts.no_backup { if !opts.no_backup {
let backup = backup_path(&target); let backup = backup_path(&target);
if opts.dry_run { if opts.dry_run {
println!( reporter.report(&LinkEvent::DryRun {
" \x1b[33mwould backup\x1b[0m {} -> {}", action: "backup",
target.display(), source: &source_display,
backup.display() target: &backup,
); });
} else { } else {
if opts.verbose { reporter.report(&LinkEvent::Backup {
println!( from: &target,
" \x1b[33mbackup\x1b[0m {} -> {}", to: &backup,
target.display(), });
backup.display()
);
}
if let Err(e) = fs.rename(&target, &backup) { if let Err(e) = fs.rename(&target, &backup) {
return save_and_return(state, e.into()); return save_and_return(state, e.into());
} }
result.backed_up += 1;
} }
} else if !opts.dry_run { } else if !opts.dry_run {
let rm_result = if fs.is_dir(&target) { let rm_result = if fs.is_dir(&target) {
@@ -171,9 +188,7 @@ pub fn link_package(
// Create parent directories // Create parent directories
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) { if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
if opts.dry_run { if opts.dry_run {
if opts.verbose { reporter.report(&LinkEvent::CreateDir { path: parent });
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
} else if let Err(e) = fs.create_dir_all(parent) { } else if let Err(e) = fs.create_dir_all(parent) {
return save_and_return(state, e.into()); return save_and_return(state, e.into());
} }
@@ -186,12 +201,13 @@ pub fn link_package(
}; };
if opts.dry_run { if opts.dry_run {
println!( reporter.report(&LinkEvent::DryRun {
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", action: action_word,
target.display() source: &source_display,
); target: &target,
});
} else { } else {
let link_result = match method { let io_result = match method {
Method::Symlink => { Method::Symlink => {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -210,14 +226,24 @@ pub fn link_package(
fs.write(&target, &content).map(|_| content.len() as u64) fs.write(&target, &content).map(|_| content.len() as u64)
} }
}; };
if let Err(e) = link_result { if let Err(e) = io_result {
return save_and_return(state, e.into()); return save_and_return(state, e.into());
} }
if opts.verbose { match method {
println!( Method::Symlink => {
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", reporter.report(&LinkEvent::Link {
target.display() source: &source_display,
); target: &target,
});
result.linked += 1;
}
Method::Copy => {
reporter.report(&LinkEvent::Copy {
source: &source_display,
target: &target,
});
result.copied += 1;
}
} }
state.add_entry(StateEntry { state.add_entry(StateEntry {
@@ -233,7 +259,7 @@ pub fn link_package(
if !opts.dry_run { if !opts.dry_run {
state.save(dotfiles_dir, fs)?; state.save(dotfiles_dir, fs)?;
} }
Ok(()) Ok(result)
} }
pub fn unlink_package( pub fn unlink_package(
@@ -242,6 +268,7 @@ pub fn unlink_package(
package: &str, package: &str,
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> { ) -> Result<(), NotfilesError> {
// Validate the package: it must either exist as a directory in dotfiles_dir // Validate the package: it must either exist as a directory in dotfiles_dir
// or have entries in state. A name that satisfies neither is a user error. // or have entries in state. A name that satisfies neither is a user error.
@@ -260,9 +287,10 @@ pub fn unlink_package(
.collect(); .collect();
if entries.is_empty() { if entries.is_empty() {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!(" {package}: nothing to unlink"); source: package,
} reason: "nothing to unlink",
});
return Ok(()); return Ok(());
} }
@@ -273,9 +301,10 @@ pub fn unlink_package(
let source = PathBuf::from(&entry.source); let source = PathBuf::from(&entry.source);
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() { if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display()); source: &target.to_string_lossy(),
} reason: "already gone",
});
if !opts.dry_run { if !opts.dry_run {
removed_entries.push((entry.source.clone(), entry.target.clone())); removed_entries.push((entry.source.clone(), entry.target.clone()));
} }
@@ -287,64 +316,59 @@ pub fn unlink_package(
// Verify it's a symlink pointing to our source // Verify it's a symlink pointing to our source
if let Ok(link_target) = fs.read_link(&target) { if let Ok(link_target) = fs.read_link(&target) {
if link_target != source { if link_target != source {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!( source: &target.to_string_lossy(),
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)", reason: "symlink points elsewhere",
target.display() });
);
}
continue; continue;
} }
} else { } else {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display()); source: &target.to_string_lossy(),
} reason: "not a symlink",
});
continue; continue;
} }
} }
Method::Copy => match (fs.read(&source), fs.read(&target)) { Method::Copy => match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {} (Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
(Ok(_), Ok(_)) => { (Ok(_), Ok(_)) => {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!( source: &target.to_string_lossy(),
" \x1b[33mskip\x1b[0m {} (copied file diverged from source)", reason: "copied file diverged from source",
target.display() });
);
}
continue; continue;
} }
(Err(_), _) => { (Err(_), _) => {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!( source: &target.to_string_lossy(),
" \x1b[33mskip\x1b[0m {} (source missing for copied file)", reason: "source missing for copied file",
target.display() });
);
}
continue; continue;
} }
(_, Err(_)) => { (_, Err(_)) => {
if opts.verbose { reporter.report(&LinkEvent::Skip {
println!( source: &target.to_string_lossy(),
" \x1b[33mskip\x1b[0m {} (cannot read copied target)", reason: "cannot read copied target",
target.display() });
);
}
continue; continue;
} }
}, },
} }
if opts.dry_run { if opts.dry_run {
println!(" \x1b[36mwould remove\x1b[0m {}", target.display()); reporter.report(&LinkEvent::DryRun {
action: "remove",
source: &target.to_string_lossy(),
target: &target,
});
} else { } else {
if fs.is_dir(&target) { if fs.is_dir(&target) {
fs.remove_dir_all(&target)?; fs.remove_dir_all(&target)?;
} else { } else {
fs.remove_file(&target)?; fs.remove_file(&target)?;
} }
if opts.verbose { reporter.report(&LinkEvent::Remove { target: &target });
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
// Clean up empty parent dirs // Clean up empty parent dirs
cleanup_empty_parents(&target, fs); cleanup_empty_parents(&target, fs);
@@ -362,6 +386,100 @@ pub fn unlink_package(
Ok(()) Ok(())
} }
#[allow(clippy::too_many_arguments)]
pub fn adopt_files(
dotfiles_dir: &Path,
config: &Config,
state: &mut State,
package: &str,
files: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let target_base = expand_tilde(config.target_for(package))?;
let mut result = LinkResult {
package: package.to_string(),
..Default::default()
};
if !fs.is_dir(&package_dir) {
if opts.dry_run {
reporter.report(&LinkEvent::CreateDir { path: &package_dir });
} else {
fs.create_dir_all(&package_dir)?;
}
}
for file in files {
let relative = PathBuf::from(file);
let target = target_base.join(&relative);
let source = package_dir.join(&relative);
let display = format!("{package}/{}", relative.display());
if !fs.exists(&target) {
reporter.report(&LinkEvent::Skip {
source: &display,
reason: "target file does not exist",
});
result.skipped += 1;
continue;
}
if fs.exists(&source) {
reporter.report(&LinkEvent::Skip {
source: &display,
reason: "already exists in package",
});
result.skipped += 1;
continue;
}
if opts.dry_run {
reporter.report(&LinkEvent::DryRun {
action: "adopt",
source: &display,
target: &target,
});
continue;
}
// Create parent dirs in package
if let Some(parent) = source.parent().filter(|p| !fs.exists(p)) {
fs.create_dir_all(parent)?;
}
// Move target -> package source
fs.rename(&target, &source)?;
// Create symlink target -> source
#[cfg(unix)]
{
fs.symlink(&source, &target)?;
}
reporter.report(&LinkEvent::Link {
source: &display,
target: &target,
});
state.add_entry(StateEntry {
package: package.to_string(),
source: source.to_string_lossy().to_string(),
target: target.to_string_lossy().to_string(),
method: Method::Symlink,
linked_at: Utc::now().to_rfc3339(),
});
result.linked += 1;
}
if !opts.dry_run {
state.save(dotfiles_dir, fs)?;
}
Ok(result)
}
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool { fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
match method { match method {
Method::Symlink => { Method::Symlink => {

View File

@@ -1,11 +1,14 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::{CommandFactory, Parser};
use std::fs; use std::fs;
use notcore::Config; use notcore::Config;
use notcore::reporter::Reporter;
use notfiles::adapters::{JsonReporter, TerminalReporter};
use notfiles::cli::{Cli, Command}; use notfiles::cli::{Cli, Command};
use notfiles::detect;
use notfiles::linker::{LinkOptions, State}; use notfiles::linker::{LinkOptions, State};
use notfiles::package::resolve_packages_with_store; use notfiles::package::{resolve_packages_filtered_with_store, resolve_packages_with_store};
use notfiles::{adapters, linker, status}; use notfiles::{adapters, linker, status};
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -16,7 +19,24 @@ fn main() -> Result<()> {
let dotfiles_dir = fs::canonicalize(&dotfiles_dir) let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
let reporter: Box<dyn Reporter> = if cli.json {
Box::new(JsonReporter)
} else {
Box::new(TerminalReporter)
};
match cli.command { match cli.command {
Command::Detect => {
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| dotfiles_dir.clone());
let managers = detect::detect(&home);
if cli.json {
detect::print_detected_json(&managers);
} else {
detect::print_detected(&managers);
}
}
Command::Init => cmd_init(&dotfiles_dir)?, Command::Init => cmd_init(&dotfiles_dir)?,
Command::Link { Command::Link {
force, force,
@@ -25,8 +45,9 @@ fn main() -> Result<()> {
} => { } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let mut state = State::load(&dotfiles_dir, fs)?; let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
let opts = LinkOptions { let opts = LinkOptions {
force, force,
no_backup, no_backup,
@@ -38,19 +59,26 @@ fn main() -> Result<()> {
println!("\x1b[36m(dry run)\x1b[0m"); println!("\x1b[36m(dry run)\x1b[0m");
} }
let mut results = Vec::new();
for pkg in &pkgs { for pkg in &pkgs {
if cli.verbose || cli.dry_run { if cli.verbose || cli.dry_run {
println!("Linking {pkg}..."); println!("Linking {pkg}...");
} }
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts, fs)?; let result = linker::link_package(
&dotfiles_dir,
&config,
&mut state,
pkg,
&opts,
fs,
&*reporter,
)?;
results.push(result);
} }
if !cli.dry_run { if !cli.dry_run {
state.save(&dotfiles_dir, fs)?; state.save(&dotfiles_dir, fs)?;
let count: usize = pkgs let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
.iter()
.map(|p| state.entries_for_package(p).len())
.sum();
println!( println!(
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m", "\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
if count == 1 { "" } else { "s" }, if count == 1 { "" } else { "s" },
@@ -95,7 +123,7 @@ fn main() -> Result<()> {
if cli.verbose || cli.dry_run { if cli.verbose || cli.dry_run {
println!("Unlinking {pkg}..."); println!("Unlinking {pkg}...");
} }
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs)?; linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?;
} }
if !cli.dry_run { if !cli.dry_run {
@@ -107,18 +135,103 @@ fn main() -> Result<()> {
); );
} }
} }
Command::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
"notfiles",
&mut std::io::stdout(),
);
}
Command::Check => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?;
if cli.json {
let skipped: Vec<String> = {
let all_unfiltered =
notfiles::package::resolve_packages_with_store(&dotfiles_dir, &[], fs)?;
all_unfiltered
.into_iter()
.filter(|p| !all.contains(p))
.collect()
};
let obj = serde_json::json!({
"valid": true,
"packages": all,
"skipped": skipped,
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("notfiles.toml: \x1b[32mvalid\x1b[0m");
println!("Packages: {} ({} total)", all.join(", "), all.len());
}
}
Command::Diff { packages } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
for pkg in &pkgs {
let entries = status::diff_package(&dotfiles_dir, &config, &state, pkg, fs);
status::print_diff(pkg, &entries);
}
}
Command::Adopt { package, files } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
let mut state = State::load(&dotfiles_dir, fs)?;
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");
}
let result = linker::adopt_files(
&dotfiles_dir,
&config,
&mut state,
&package,
&files,
&opts,
fs,
&*reporter,
)?;
if !cli.dry_run {
println!(
"\x1b[32mAdopted {} file{} into {package}.\x1b[0m",
result.linked,
if result.linked == 1 { "" } else { "s" },
);
}
}
Command::Status { packages } => { Command::Status { packages } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
for pkg in &pkgs { for pkg in &pkgs {
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs); let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
if cli.json {
status::print_status_json(pkg, &entries);
} else {
status::print_status(pkg, &entries); status::print_status(pkg, &entries);
} }
} }
} }
}
Ok(()) Ok(())
} }

View File

@@ -5,6 +5,38 @@ use crate::ignore::IgnoreMatcher;
use crate::ports::FileStore; use crate::ports::FileStore;
use notcore::{Config, NotfilesError}; use notcore::{Config, NotfilesError};
/// Discover packages filtered by the config's include/exclude lists and
/// platform constraints.
pub fn discover_packages_filtered(
dotfiles_dir: &Path,
config: &Config,
) -> Result<Vec<String>, NotfilesError> {
discover_packages_filtered_with_store(dotfiles_dir, config, &FileStoreImpl)
}
pub fn discover_packages_filtered_with_store(
dotfiles_dir: &Path,
config: &Config,
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let all = discover_packages_with_store(dotfiles_dir, fs)?;
let filtered: Vec<String> = if let Some(include) = config.included_packages() {
all.into_iter()
.filter(|p| include.contains(&p.as_str()))
.collect()
} else {
all.into_iter()
.filter(|p| !config.is_package_excluded(p))
.collect()
};
// Apply platform filter
let filtered = filtered
.into_iter()
.filter(|p| config.is_package_for_current_platform(p))
.collect();
Ok(filtered)
}
/// Discover available packages (subdirectories of the dotfiles dir). /// Discover available packages (subdirectories of the dotfiles dir).
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> { pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
discover_packages_with_store(dotfiles_dir, &FileStoreImpl) discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
@@ -48,13 +80,28 @@ pub fn resolve_packages_with_store(
requested: &[String], requested: &[String],
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> { ) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages_with_store(dotfiles_dir, fs)?; resolve_packages_filtered_with_store(dotfiles_dir, requested, &Config::default(), fs)
}
pub fn resolve_packages_filtered_with_store(
dotfiles_dir: &Path,
requested: &[String],
config: &Config,
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages_filtered_with_store(dotfiles_dir, config, fs)?;
if requested.is_empty() { if requested.is_empty() {
return Ok(available); return Ok(available);
} }
for name in requested { for name in requested {
if !available.contains(name) { if !available.contains(name) {
return Err(NotfilesError::PackageNotFound { name: name.clone() }); let available_refs: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
let suggestion = notcore::suggest_package(name, &available_refs);
let mut msg = name.clone();
if let Some(s) = suggestion {
msg = format!("{name} (did you mean '{s}'?)");
}
return Err(NotfilesError::PackageNotFound { name: msg });
} }
} }
Ok(requested.to_vec()) Ok(requested.to_vec())
@@ -123,6 +170,40 @@ mod tests {
assert_eq!(pkgs, vec!["git", "zsh"]); assert_eq!(pkgs, vec!["git", "zsh"]);
} }
#[test]
fn test_discover_respects_include_filter() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
fs::create_dir(tmp.path().join("zsh")).unwrap();
fs::create_dir(tmp.path().join("scripts")).unwrap();
let toml_str = r#"
[defaults]
target = "~"
include = ["git", "zsh"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
assert_eq!(pkgs, vec!["git", "zsh"]);
}
#[test]
fn test_discover_respects_exclude_filter() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
fs::create_dir(tmp.path().join("scripts")).unwrap();
fs::create_dir(tmp.path().join("docs")).unwrap();
let toml_str = r#"
[defaults]
target = "~"
exclude = ["scripts", "docs"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
assert_eq!(pkgs, vec!["git"]);
}
#[test] #[test]
fn test_resolve_specific_packages() { fn test_resolve_specific_packages() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde_json::json;
use crate::linker::State; use crate::linker::State;
use crate::package::collect_files_with_store; use crate::package::collect_files_with_store;
use crate::ports::FileStore; use crate::ports::FileStore;
@@ -121,6 +123,126 @@ pub fn package_status(
results results
} }
#[derive(Debug, PartialEq)]
pub enum DiffKind {
Identical,
Modified,
SourceOnly,
TargetOnly,
}
pub struct DiffEntry {
pub source_display: String,
pub target: PathBuf,
pub kind: DiffKind,
}
pub fn diff_package(
dotfiles_dir: &Path,
config: &Config,
state: &State,
package: &str,
fs: &dyn FileStore,
) -> Vec<DiffEntry> {
let mut results = Vec::new();
let method = config.method_for(package);
if method != Method::Copy {
return results;
}
let package_dir = dotfiles_dir.join(package);
let target_base = match expand_tilde(config.target_for(package)) {
Ok(p) => p,
Err(_) => return results,
};
// Check files tracked in state for this package
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
let target = PathBuf::from(&entry.target);
let source_display = format!(
"{package}/{}",
source
.strip_prefix(&package_dir)
.unwrap_or(&source)
.display()
);
let kind = match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) => {
if src == tgt {
DiffKind::Identical
} else {
DiffKind::Modified
}
}
(Ok(_), Err(_)) => DiffKind::SourceOnly,
(Err(_), Ok(_)) => DiffKind::TargetOnly,
(Err(_), Err(_)) => continue,
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
// Also check untracked files from the package dir
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
// Skip if already covered by state entries
if results.iter().any(|r| r.target == target) {
continue;
}
let source_display = format!("{package}/{}", relative.display());
let kind = if !fs.exists(&target) {
DiffKind::SourceOnly
} else {
match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
(Ok(_), Ok(_)) => DiffKind::Modified,
_ => continue,
}
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
}
results
}
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
let non_identical: Vec<_> = entries
.iter()
.filter(|e| e.kind != DiffKind::Identical)
.collect();
if non_identical.is_empty() {
println!(" {package}: all copies identical");
return;
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in &non_identical {
let label = match entry.kind {
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
DiffKind::Identical => unreachable!(),
};
println!(
" {label} {} -> {}",
entry.source_display,
entry.target.display()
);
}
}
pub fn print_status(package: &str, entries: &[StatusEntry]) { pub fn print_status(package: &str, entries: &[StatusEntry]) {
if entries.is_empty() { if entries.is_empty() {
println!(" {package}: (empty)"); println!(" {package}: (empty)");
@@ -136,3 +258,18 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
); );
} }
} }
pub fn print_status_json(package: &str, entries: &[StatusEntry]) {
let items: Vec<_> = entries
.iter()
.map(|e| {
json!({
"source": e.source_display,
"target": e.target.to_string_lossy(),
"status": format!("{:?}", e.status).to_lowercase(),
})
})
.collect();
let obj = json!({"package": package, "entries": items});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
}

View File

@@ -0,0 +1,23 @@
# Test Fixture — Dotfiles
This directory is a test fixture representing a realistic dotfiles repo.
It is used by notfiles integration tests and serves as an example of the
expected directory structure.
## Package directories (linked to ~)
- `git/``.gitconfig` at root
- `zsh/``.zshrc` at root
- `nushell/``.config/nushell/` tree
- `starship/``.config/starship.toml`
- `alacritty/``.config/alacritty/alacritty.toml`
- `mise/``.config/mise/config.toml`
- `fish/``.config/fish/config.fish`
- `nixos/` — platform-gated to Linux only
- `vscode/` — deep `Library/Application Support/` path (macOS)
## Non-package directories (excluded via include list)
- `scripts/` — shell scripts, not a stow target
- `docs/` — documentation
- `secrets/` — encrypted env files, copy-only with ignore

View File

@@ -0,0 +1,2 @@
[font]
size = 14.0

View File

@@ -0,0 +1,3 @@
# Bootstrap Runbook
Steps for setting up a new machine.

View File

@@ -0,0 +1,2 @@
# Example fish config
set -gx EDITOR nvim

View File

@@ -0,0 +1,5 @@
[user]
name = Example User
email = user@example.com
[init]
defaultBranch = main

View File

@@ -0,0 +1,2 @@
[tools]
node = "lts"

View File

@@ -0,0 +1,4 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [ vim git ];
}

View File

@@ -0,0 +1,34 @@
# Example notfiles.toml — mirrors a realistic dotfiles repo.
#
# This fixture is used by integration tests and serves as documentation
# for how to configure notfiles with a mixed-purpose dotfiles repo.
[defaults]
target = "~"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml"]
# Only link actual stow packages — skip operational dirs.
include = ["git", "zsh", "nushell", "starship", "alacritty", "mise", "fish", "nixos", "vscode"]
[packages.git]
[packages.zsh]
[packages.nushell]
[packages.starship]
[packages.alacritty]
[packages.mise]
[packages.fish]
[packages.nixos]
platforms = ["linux"]
[packages.vscode]
[packages.secrets]
method = "copy"
ignore = ["*.example"]

View File

@@ -0,0 +1,2 @@
# Example aliases
alias ll = ls -l

View File

@@ -0,0 +1,2 @@
# Example nushell config
$env.config.show_banner = false

View File

@@ -0,0 +1,2 @@
# Example nushell env
$env.EDITOR = "nvim"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Health check script — NOT a stow package.
echo "All good"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Shared helpers — NOT a stow package.
log() { echo "[$(date)] $*"; }

View File

@@ -0,0 +1,3 @@
{
"EXAMPLE_KEY": "replace-me"
}

View File

@@ -0,0 +1,2 @@
[character]
success_symbol = "[>](bold green)"

View File

@@ -0,0 +1,4 @@
{
"editor.fontSize": 14,
"editor.tabSize": 2
}

View File

@@ -0,0 +1,2 @@
# Example zshrc
export EDITOR=nvim

View File

@@ -463,3 +463,164 @@ fn test_unlink_cleans_empty_dirs() {
// The .config/zsh directory should be cleaned up // The .config/zsh directory should be cleaned up
assert!(!target.join(".config/zsh").exists()); assert!(!target.join(".config/zsh").exists());
} }
// ── Fixture-based tests ─────────────────────────────────────────────────────
/// Copy the static fixture into a temp dir with a rewritten target, so tests
/// don't touch the real home directory.
fn setup_from_fixture(tmp: &TempDir) {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dotfiles");
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
fs::create_dir_all(&target).unwrap();
copy_dir_recursive(&fixture, &dotfiles).unwrap();
// Rewrite notfiles.toml to point target at our temp home
let config_path = dotfiles.join("notfiles.toml");
let content = fs::read_to_string(&config_path).unwrap();
let rewritten = content.replace(
"target = \"~\"",
&format!("target = \"{}\"", target.display()),
);
fs::write(&config_path, rewritten).unwrap();
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[test]
fn test_fixture_include_excludes_non_packages() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
// Stow packages should be linked
assert!(
target.join(".gitconfig").exists(),
".gitconfig should be linked"
);
assert!(target.join(".zshrc").exists(), ".zshrc should be linked");
assert!(
target.join(".config/nushell/config.nu").exists(),
"nushell config should be linked",
);
assert!(
target.join(".config/starship.toml").exists(),
"starship config should be linked",
);
// Non-package dirs should NOT be linked
assert!(
!target.join("lib/common.sh").exists(),
"scripts/ should not be linked",
);
assert!(
!target.join("bootstrap-runbook.md").exists(),
"docs/ should not be linked",
);
assert!(
!target.join("doctor.sh").exists(),
"scripts/ should not be linked",
);
}
#[test]
fn test_fixture_status_shows_all_packages() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let (stdout, _, ok) = run(&dotfiles, &["status"]);
assert!(ok);
// Should list stow packages
assert!(stdout.contains("git"), "status should show git package");
assert!(stdout.contains("zsh"), "status should show zsh package");
assert!(
stdout.contains("nushell"),
"status should show nushell package"
);
// Should NOT list excluded dirs
assert!(
!stdout.contains("scripts"),
"status should not show scripts"
);
assert!(!stdout.contains("docs"), "status should not show docs");
}
#[test]
fn test_fixture_deep_paths_link_correctly() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link", "vscode"]);
assert!(ok);
let vscode_settings = target.join("Library/Application Support/Code/User/settings.json");
assert!(
vscode_settings.exists(),
"deep path should be linked: {}",
vscode_settings.display(),
);
}
#[test]
fn test_fixture_platform_filter_skips_linux_on_macos() {
if !cfg!(target_os = "macos") {
return; // This test is macOS-specific
}
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
// nixos has platforms = ["linux"], so it should NOT be linked on macOS
assert!(
!target.join("configuration.nix").exists(),
"nixos package should be skipped on macOS",
);
// But macos-compatible packages should be linked
assert!(target.join(".gitconfig").exists());
}
#[test]
fn test_fixture_link_and_unlink_round_trip() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Link all
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
assert!(target.join(".gitconfig").exists());
// Unlink all
let (_, _, ok) = run(&dotfiles, &["unlink"]);
assert!(ok);
assert!(!target.join(".gitconfig").exists());
assert!(!target.join(".zshrc").exists());
}

BIN
crates/notgraph/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,4 +1,5 @@
use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph}; use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> { pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> {
@@ -66,7 +67,7 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut by_fan_in = stats.to_vec(); let mut by_fan_in = stats.to_vec();
by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in)); by_fan_in.sort_by_key(|b| Reverse(b.fan_in));
for s in by_fan_in.iter().take(top_n) { for s in by_fan_in.iter().take(top_n) {
if s.fan_in > 0 { if s.fan_in > 0 {
result.push(Hotspot { result.push(Hotspot {
@@ -78,7 +79,7 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
} }
let mut by_fan_out = stats.to_vec(); let mut by_fan_out = stats.to_vec();
by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out)); by_fan_out.sort_by_key(|b| Reverse(b.fan_out));
for s in by_fan_out.iter().take(top_n) { for s in by_fan_out.iter().take(top_n) {
if s.fan_out > 0 { if s.fan_out > 0 {
result.push(Hotspot { result.push(Hotspot {

BIN
crates/nothooks/.DS_Store vendored Normal file

Binary file not shown.

17
crates/notnet/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "notnet"
version = "0.1.0"
edition = "2024"
license.workspace = true
description = "Tailscale network presence management for notstrap"
[dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true }
rpassword = { workspace = true }
serde = { workspace = true }
yubikey = { version = "0.8", optional = true }
[features]
default = []
yubikey = ["dep:yubikey"]

49
crates/notnet/src/auth.rs Normal file
View File

@@ -0,0 +1,49 @@
/// Auth key resolution chain for Tailscale:
///
/// 1. `TS_AUTHKEY` environment variable
/// 2. YubiKey PIV slot 9d (when compiled with the `yubikey` feature)
/// 3. Interactive prompt
///
/// Returns `Err(NotnetError::NoAuthKey)` if all sources are exhausted without a key.
use crate::error::NotnetError;
pub fn resolve_auth_key() -> Result<String, NotnetError> {
// 1. Environment variable
if let Ok(key) = std::env::var("TS_AUTHKEY")
&& !key.is_empty()
{
return Ok(key);
}
// 2. YubiKey PIV slot 9d
#[cfg(feature = "yubikey")]
if let Some(key) = read_yubikey_slot_9d() {
return Ok(key);
}
// 3. Interactive prompt
prompt_auth_key()
}
#[cfg(feature = "yubikey")]
fn read_yubikey_slot_9d() -> Option<String> {
use yubikey::{YubiKey, piv};
let mut yk = YubiKey::open().ok()?;
// Slot 9d — key management slot, repurposed here for the Tailscale auth key.
let slot = piv::SlotId::KeyManagement; // 0x9d
let data = piv::read_object(&mut yk, piv::ObjectId::from(slot)).ok()?;
let key = String::from_utf8(data.to_vec()).ok()?;
let key = key.trim().to_string();
if key.is_empty() { None } else { Some(key) }
}
fn prompt_auth_key() -> Result<String, NotnetError> {
let key = rpassword::prompt_password("Tailscale auth key: ").map_err(NotnetError::Io)?;
let key = key.trim().to_string();
if key.is_empty() {
Err(NotnetError::NoAuthKey)
} else {
Ok(key)
}
}

View File

@@ -0,0 +1,24 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NotnetError {
#[error("Tailscale is not installed; set install = true in [tailscale] to auto-install")]
NotInstalled,
#[error("Tailscale install failed: {0}")]
InstallFailed(String),
#[error("command `{cmd}` failed: {detail}")]
CommandFailed { cmd: String, detail: String },
#[error("peer `{0}` is unreachable over Tailscale")]
PeerUnreachable(String),
#[error(
"auth key resolution failed: no TS_AUTHKEY env var, no YubiKey on slot 9d, and user declined prompt"
)]
NoAuthKey,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}

View File

@@ -0,0 +1,92 @@
use crate::error::NotnetError;
/// Returns true if the `tailscale` binary is on PATH.
pub fn is_installed() -> bool {
std::process::Command::new("tailscale")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Install Tailscale using the platform package manager, falling back to the official
/// install script.
///
/// Supported package managers (tried in order): `apt-get`, `brew`, `pacman`.
/// Fallback: `curl -fsSL https://tailscale.com/install.sh | sh`.
pub fn install() -> Result<(), NotnetError> {
if try_apt().is_ok() {
return Ok(());
}
if try_brew().is_ok() {
return Ok(());
}
if try_pacman().is_ok() {
return Ok(());
}
install_via_script()
}
fn try_apt() -> Result<(), NotnetError> {
// apt-get install tailscale requires the tailscale repo to be configured already.
// We only attempt this if apt-get is present — the user is expected to have added
// the Tailscale apt repo as part of their base image or preseed.
if !cmd_exists("apt-get") {
return Err(not_available("apt-get"));
}
run_cmd("apt-get", &["install", "-y", "tailscale"])
}
fn try_brew() -> Result<(), NotnetError> {
if !cmd_exists("brew") {
return Err(not_available("brew"));
}
run_cmd("brew", &["install", "tailscale"])
}
fn try_pacman() -> Result<(), NotnetError> {
if !cmd_exists("pacman") {
return Err(not_available("pacman"));
}
run_cmd("pacman", &["-S", "--noconfirm", "tailscale"])
}
fn install_via_script() -> Result<(), NotnetError> {
// Pipe the official install script through sh.
// `curl | sh` is the documented method on tailscale.com/download.
let output = std::process::Command::new("sh")
.args(["-c", "curl -fsSL https://tailscale.com/install.sh | sh"])
.output()
.map_err(|e| NotnetError::InstallFailed(e.to_string()))?;
if !output.status.success() {
return Err(NotnetError::InstallFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(())
}
fn cmd_exists(cmd: &str) -> bool {
std::process::Command::new("sh")
.args(["-c", &format!("command -v {cmd}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn run_cmd(program: &str, args: &[&str]) -> Result<(), NotnetError> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| NotnetError::InstallFailed(e.to_string()))?;
if !output.status.success() {
return Err(NotnetError::InstallFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(())
}
fn not_available(name: &str) -> NotnetError {
NotnetError::InstallFailed(format!("{name} not found"))
}

80
crates/notnet/src/lib.rs Normal file
View File

@@ -0,0 +1,80 @@
pub mod auth;
pub mod error;
pub mod installer;
pub mod ports;
pub use error::NotnetError;
pub use ports::TailscaleOptions;
use anyhow::Result;
/// Ensure this machine is connected to the tailnet and the target peer is reachable.
///
/// Returns immediately (skipped) if `tailscale status` reports an active connection.
/// Installs Tailscale if `opts.install` is true and the binary is missing.
pub fn ensure_connected(opts: &TailscaleOptions) -> Result<bool, NotnetError> {
// Already connected — fast path.
if status::is_connected() {
return Ok(false); // false = skipped (already on tailnet)
}
// Install if missing and opts.install allows it.
if !installer::is_installed() {
if !opts.install {
return Err(NotnetError::NotInstalled);
}
installer::install()?;
}
// Resolve auth key.
let auth_key = auth::resolve_auth_key()?;
// Join the tailnet.
join(&auth_key)?;
// Verify target peer is reachable.
verify_peer(&opts.peer_hostname)?;
Ok(true) // true = connected now
}
fn join(auth_key: &str) -> Result<(), NotnetError> {
let output = std::process::Command::new("tailscale")
.args(["up", &format!("--authkey={auth_key}")])
.output()
.map_err(|e| NotnetError::CommandFailed {
cmd: "tailscale up".to_string(),
detail: e.to_string(),
})?;
if !output.status.success() {
return Err(NotnetError::CommandFailed {
cmd: "tailscale up".to_string(),
detail: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(())
}
fn verify_peer(hostname: &str) -> Result<(), NotnetError> {
let output = std::process::Command::new("tailscale")
.args(["ping", "--c", "1", hostname])
.output()
.map_err(|e| NotnetError::CommandFailed {
cmd: format!("tailscale ping {hostname}"),
detail: e.to_string(),
})?;
if !output.status.success() {
return Err(NotnetError::PeerUnreachable(hostname.to_string()));
}
Ok(())
}
mod status {
pub fn is_connected() -> bool {
std::process::Command::new("tailscale")
.args(["status", "--peers=false"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}

View File

@@ -0,0 +1,13 @@
use serde::Deserialize;
/// Configuration for Tailscale network setup.
#[derive(Debug, Clone, Deserialize)]
pub struct TailscaleOptions {
/// Tailscale hostname of the peer to verify after joining (e.g. "minibox").
pub peer_hostname: String,
/// Gitea clone URL reachable over the tailnet (e.g. "http://minibox:3000/joe/dotfiles.git").
pub gitea_url: String,
/// If true, install Tailscale automatically when the binary is missing.
#[serde(default)]
pub install: bool,
}

BIN
crates/notsecrets/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -10,9 +10,10 @@ anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
rpassword = { workspace = true } rpassword = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
x25519-dalek = { version = "2", features = ["static_secrets"] } x25519-dalek = { version = "2", features = ["static_secrets"] }
ed25519-dalek = "2" ed25519-dalek = "2"
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10" sha2 = "0.10"
hkdf = "0.12" hkdf = "0.12"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
@@ -23,6 +24,11 @@ hmac = "0.12"
rand = "0.8" rand = "0.8"
zeroize = "1" zeroize = "1"
curve25519-dalek = "4" curve25519-dalek = "4"
yubikey = { version = "0.8", optional = true }
[features]
default = []
yubikey = ["dep:yubikey"]
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@@ -0,0 +1,170 @@
use crate::error::SecretsError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Env,
Op,
Dotenvx,
Sops,
Gsm,
Nuenv,
Direnv,
Mise,
Bitwarden,
Vault,
Dotenvy,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ProviderConfig {
Env,
Op { account: String },
Dotenvx { env_file: PathBuf },
Sops { file: PathBuf },
Gsm { project: String },
Nuenv,
Direnv,
Mise,
Bitwarden { server_url: Option<String> },
Vault { addr: String, mount: Option<String> },
Dotenvy { path: PathBuf },
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum SecretRef {
Env,
Op { uri: String },
Dotenvx { key: Option<String> },
Sops { key: Option<String> },
Gsm { path: String },
Nuenv { key: Option<String> },
Direnv { key: Option<String> },
Mise { key: Option<String> },
Bitwarden { item: String, field: Option<String> },
Vault { path: String, field: Option<String> },
Dotenvy { key: Option<String> },
}
impl SecretRef {
pub fn provider(&self) -> Provider {
match self {
Self::Env { .. } => Provider::Env,
Self::Op { .. } => Provider::Op,
Self::Dotenvx { .. } => Provider::Dotenvx,
Self::Sops { .. } => Provider::Sops,
Self::Gsm { .. } => Provider::Gsm,
Self::Nuenv { .. } => Provider::Nuenv,
Self::Direnv { .. } => Provider::Direnv,
Self::Mise { .. } => Provider::Mise,
Self::Bitwarden { .. } => Provider::Bitwarden,
Self::Vault { .. } => Provider::Vault,
Self::Dotenvy { .. } => Provider::Dotenvy,
}
}
}
#[derive(Debug, Deserialize)]
pub struct SecretsConfig {
pub providers: Vec<Provider>,
#[serde(default)]
pub provider: HashMap<Provider, ProviderConfig>,
#[serde(default)]
pub secrets: HashMap<String, SecretRef>,
}
pub fn load_config(path: &Path) -> Result<SecretsConfig, SecretsError> {
let content = std::fs::read_to_string(path)
.map_err(|e| SecretsError::Config(format!("cannot read {}: {e}", path.display())))?;
toml::from_str(&content).map_err(|e| SecretsError::Config(format!("parse error: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_deserializes_from_string() {
let p: Provider = toml::Value::String("op".to_string()).try_into().unwrap();
assert_eq!(p, Provider::Op);
}
#[test]
fn secrets_config_parses_minimal() {
let toml_str = r#"
providers = ["env", "op"]
"#;
let cfg: SecretsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.providers, vec![Provider::Env, Provider::Op]);
assert!(cfg.provider.is_empty());
assert!(cfg.secrets.is_empty());
}
#[test]
fn secrets_config_parses_full() {
let toml_str = r#"
providers = ["env", "op", "dotenvx", "sops", "gsm"]
[provider.op]
type = "op"
account = "my.1password.com"
[provider.dotenvx]
type = "dotenvx"
env_file = "~/dev/.env"
[provider.sops]
type = "sops"
file = "secrets/bootstrap.sops.env"
[provider.gsm]
type = "gsm"
project = "my-gcp-project"
[secrets]
DATABASE_URL = { source = "op", uri = "op://Personal/db/url" }
ANTHROPIC_API_KEY = { source = "dotenvx" }
GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" }
"#;
let cfg: SecretsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.providers.len(), 5);
assert_eq!(cfg.provider.len(), 4);
assert_eq!(cfg.secrets.len(), 3);
assert_eq!(cfg.secrets["DATABASE_URL"].provider(), Provider::Op);
}
#[test]
fn secret_ref_provider_derivation() {
let r = SecretRef::Gsm {
path: "projects/123/secrets/tok/versions/latest".to_string(),
};
assert_eq!(r.provider(), Provider::Gsm);
}
#[test]
fn unknown_provider_in_toml_errors() {
let toml_str = r#"providers = ["env", "redis"]"#;
let result: Result<SecretsConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn load_config_reads_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("notsecrets.toml");
std::fs::write(&path, "providers = [\"env\"]\n").unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.providers, vec![Provider::Env]);
}
#[test]
fn load_config_missing_file_errors() {
let result = load_config(std::path::Path::new("/nonexistent/notsecrets.toml"));
assert!(result.is_err());
}
}

View File

@@ -13,3 +13,42 @@ pub enum AgeError {
#[error("identity source failed ({name}): {source}")] #[error("identity source failed ({name}): {source}")]
SourceError { name: String, source: anyhow::Error }, SourceError { name: String, source: anyhow::Error },
} }
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("[{name}] {source}")]
SourceError { name: String, source: anyhow::Error },
#[error("no provider resolved key: {key}")]
NotFound { key: String },
#[error("config error: {0}")]
Config(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secrets_error_display_source_error() {
let err = SecretsError::SourceError {
name: "op".to_string(),
source: anyhow::anyhow!("not found"),
};
assert!(err.to_string().contains("[op]"));
assert!(err.to_string().contains("not found"));
}
#[test]
fn secrets_error_display_not_found() {
let err = SecretsError::NotFound {
key: "DB_URL".to_string(),
};
assert!(err.to_string().contains("DB_URL"));
}
#[test]
fn secrets_error_display_config() {
let err = SecretsError::Config("bad toml".to_string());
assert!(err.to_string().contains("bad toml"));
}
}

View File

@@ -9,9 +9,6 @@ pub use ssh_ed25519::SshEd25519Identity;
pub mod scrypt; pub mod scrypt;
pub use scrypt::ScryptIdentity; pub use scrypt::ScryptIdentity;
pub mod ssh_rsa;
pub use ssh_rsa::SshRsaIdentity;
pub mod encrypted; pub mod encrypted;
pub use encrypted::EncryptedIdentity; pub use encrypted::EncryptedIdentity;

View File

@@ -1,110 +0,0 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Identity, Stanza};
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPrivateKey, RsaPublicKey, traits::PublicKeyParts};
use sha2::{Digest, Sha256};
const TAG: &str = "ssh-rsa";
pub struct SshRsaIdentity {
private_key: RsaPrivateKey,
}
impl SshRsaIdentity {
pub fn from_private_key(private_key: RsaPrivateKey) -> Self {
Self { private_key }
}
fn fingerprint(&self) -> [u8; 4] {
rsa_pubkey_fingerprint(&RsaPublicKey::from(&self.private_key))
}
}
impl Identity for SshRsaIdentity {
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
if stanza.tag != TAG {
return None;
}
if stanza.args.len() != 1 {
return Some(Err(AgeError::ParseError(
"ssh-rsa stanza must have 1 arg".to_string(),
)));
}
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp.as_slice() != self.fingerprint() {
return None;
}
Some(unwrap(stanza, &self.private_key))
}
}
fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result<FileKey, AgeError> {
let padding = Oaep::new::<Sha256>();
let file_key_bytes = private_key
.decrypt(padding, &stanza.body)
.map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?;
FileKey::try_from(file_key_bytes.as_slice())
}
pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
let n_bytes = public_key.n().to_bytes_be();
let e_bytes = public_key.e().to_bytes_be();
let mut hasher = Sha256::new();
hasher.update(&n_bytes);
hasher.update(&e_bytes);
let hash = hasher.finalize();
hash[..4].try_into().expect("SHA-256 is 32 bytes")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recipients::Recipient;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use rand::rngs::OsRng;
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed");
let public_key = RsaPublicKey::from(&private_key);
(private_key, public_key)
}
#[test]
fn ssh_rsa_wrap_unwrap_roundtrip() {
let (private_key, public_key) = test_rsa_keypair();
let recipient = SshRsaRecipient::from_public_key(public_key);
let identity = SshRsaIdentity::from_private_key(private_key);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-rsa");
assert_eq!(stanza.args.len(), 1);
let unwrapped = identity
.unwrap_file_key(&stanza)
.expect("identity should match")
.expect("unwrap should succeed");
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
}
#[test]
fn ssh_rsa_wrong_fingerprint_returns_none() {
let (private_key1, _) = test_rsa_keypair();
let (_, public_key2) = test_rsa_keypair();
let recipient = SshRsaRecipient::from_public_key(public_key2);
let identity = SshRsaIdentity::from_private_key(private_key1);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).unwrap();
assert!(identity.unwrap_file_key(&stanza).is_none());
}
}

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod decrypt; pub mod decrypt;
pub mod encrypt; pub mod encrypt;
pub mod error; pub mod error;
@@ -5,20 +6,25 @@ pub mod format;
pub mod identities; pub mod identities;
pub mod ports; pub mod ports;
pub mod recipients; pub mod recipients;
pub mod resolver;
pub mod sources; pub mod sources;
pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config};
pub use decrypt::Decryptor; pub use decrypt::Decryptor;
pub use encrypt::Encryptor; pub use encrypt::Encryptor;
pub use error::AgeError; pub use error::AgeError;
pub use error::SecretsError;
pub use identities::{ pub use identities::{
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
SshRsaIdentity, Stanza, X25519Identity, X25519Identity,
}; };
pub use ports::IdentitySource; pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
pub use recipients::{ pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, X25519Recipient};
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient, pub use resolver::SecretResolver;
pub use sources::{
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,
MiseSource, NuenvSource, OpSource, PromptSource, SopsSource, VaultSource, YubikeySource,
}; };
pub use sources::{BitwardenSource, FileSource, PromptSource};
/// Try each `IdentitySource` in order; collect all identities that load successfully. /// Try each `IdentitySource` in order; collect all identities that load successfully.
/// ///

View File

@@ -1,5 +1,7 @@
use crate::error::AgeError; use crate::config::{Provider, SecretRef};
use crate::error::{AgeError, SecretsError};
use crate::identities::Identity; use crate::identities::Identity;
use std::collections::HashMap;
/// Infra boundary trait: an identity resolver that loads key material from an /// Infra boundary trait: an identity resolver that loads key material from an
/// external source and returns a concrete Identity. /// external source and returns a concrete Identity.
@@ -10,3 +12,51 @@ pub trait IdentitySource {
/// Load and return a concrete identity from the source. /// Load and return a concrete identity from the source.
fn load(&self) -> Result<Box<dyn Identity>, AgeError>; fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
} }
/// Port: resolve a single secret by key name from an external provider.
pub trait SecretSource {
fn name(&self) -> &str;
fn provider(&self) -> Provider;
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
/// Resolve using a provider-specific typed reference.
/// Default: ignores ref, falls back to resolve(key).
fn resolve_ref(
&self,
key: &str,
_secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
self.resolve(key)
}
}
/// Extended port: sources that can enumerate all available secrets.
pub trait EnumerableSecretSource: SecretSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeSource;
impl SecretSource for FakeSource {
fn name(&self) -> &str {
"fake"
}
fn provider(&self) -> Provider {
Provider::Env
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(Some("val".to_string()))
}
}
#[test]
fn default_resolve_ref_delegates_to_resolve() {
let source = FakeSource;
let secret_ref = SecretRef::Env;
let result = source.resolve_ref("KEY", &secret_ref).unwrap();
assert_eq!(result, Some("val".to_string()));
}
}

View File

@@ -10,9 +10,6 @@ pub use ssh_ed25519::SshEd25519Recipient;
pub mod scrypt; pub mod scrypt;
pub use scrypt::ScryptRecipient; pub use scrypt::ScryptRecipient;
pub mod ssh_rsa;
pub use ssh_rsa::SshRsaRecipient;
/// Domain port: a recipient that can wrap a file key into a stanza. /// Domain port: a recipient that can wrap a file key into a stanza.
pub trait Recipient { pub trait Recipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>; fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;

View File

@@ -1,38 +0,0 @@
use crate::error::AgeError;
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPublicKey};
use sha2::Sha256;
const TAG: &str = "ssh-rsa";
pub struct SshRsaRecipient {
public_key: RsaPublicKey,
}
impl SshRsaRecipient {
pub fn from_public_key(public_key: RsaPublicKey) -> Self {
Self { public_key }
}
}
impl Recipient for SshRsaRecipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
use rand::rngs::OsRng;
let padding = Oaep::new::<Sha256>();
let wrapped = self
.public_key
.encrypt(&mut OsRng, padding, file_key.as_bytes())
.map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?;
let fingerprint = rsa_pubkey_fingerprint(&self.public_key);
Ok(Stanza {
tag: TAG.to_string(),
args: vec![STANDARD_NO_PAD.encode(fingerprint)],
body: wrapped,
})
}
}

View File

@@ -0,0 +1,222 @@
use crate::config::{Provider, ProviderConfig, SecretsConfig};
use crate::error::SecretsError;
use crate::ports::{EnumerableSecretSource, SecretSource};
use crate::sources::*;
use std::collections::HashMap;
pub struct SecretResolver {
config: SecretsConfig,
sources: Vec<Box<dyn SecretSource>>,
enumerable: Vec<Box<dyn EnumerableSecretSource>>,
}
impl SecretResolver {
pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError> {
// Validate: every binding references a provider in the chain
for (key, binding) in &config.secrets {
if !config.providers.contains(&binding.provider()) {
return Err(SecretsError::Config(format!(
"secret '{key}' references provider '{:?}' not in providers list",
binding.provider()
)));
}
}
let mut sources: Vec<Box<dyn SecretSource>> = Vec::new();
let mut enumerable: Vec<Box<dyn EnumerableSecretSource>> = Vec::new();
for provider in &config.providers {
let provider_config = config.provider.get(provider);
match provider {
Provider::Env => {
enumerable.push(Box::new(EnvSource));
sources.push(Box::new(EnvSource));
}
Provider::Op => {
let account = match provider_config {
Some(ProviderConfig::Op { account }) => account.clone(),
_ => {
return Err(SecretsError::Config(
"op provider requires [provider.op] with account".to_string(),
));
}
};
sources.push(Box::new(OpSource::new(account)));
}
Provider::Dotenvx => {
let env_file = match provider_config {
Some(ProviderConfig::Dotenvx { env_file }) => env_file.clone(),
_ => {
return Err(SecretsError::Config(
"dotenvx provider requires [provider.dotenvx] with env_file"
.to_string(),
));
}
};
enumerable.push(Box::new(DotenvxSource::new(env_file.clone())));
sources.push(Box::new(DotenvxSource::new(env_file)));
}
Provider::Sops => {
let file = match provider_config {
Some(ProviderConfig::Sops { file }) => file.clone(),
_ => {
return Err(SecretsError::Config(
"sops provider requires [provider.sops] with file".to_string(),
));
}
};
enumerable.push(Box::new(SopsSource::new(file.clone())));
sources.push(Box::new(SopsSource::new(file)));
}
Provider::Gsm => {
let project = match provider_config {
Some(ProviderConfig::Gsm { project }) => project.clone(),
_ => {
return Err(SecretsError::Config(
"gsm provider requires [provider.gsm] with project".to_string(),
));
}
};
sources.push(Box::new(GsmSource::new(project)));
}
Provider::Nuenv => sources.push(Box::new(NuenvSource)),
Provider::Direnv => sources.push(Box::new(DirenvSource)),
Provider::Mise => sources.push(Box::new(MiseSource)),
Provider::Bitwarden => {
let item_name = match provider_config {
Some(ProviderConfig::Bitwarden { .. }) => "default".to_string(),
_ => "default".to_string(),
};
sources.push(Box::new(BitwardenSource::new(item_name)));
}
Provider::Vault => sources.push(Box::new(VaultSource)),
Provider::Dotenvy => sources.push(Box::new(DotenvySource)),
}
}
Ok(Self {
config,
sources,
enumerable,
})
}
pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
// 1. Explicit binding
if let Some(secret_ref) = self.config.secrets.get(key) {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) {
return source.resolve_ref(key, secret_ref);
}
}
// 2. Priority chain
for source in &self.sources {
if let Some(value) = source.resolve(key)? {
return Ok(Some(value));
}
}
Ok(None)
}
pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
let mut map = HashMap::new();
// 1. Merge enumerable sources (priority order, first wins)
for source in &self.enumerable {
let source_map = source.resolve_all()?;
for (k, v) in source_map {
map.entry(k).or_insert(v);
}
}
// 2. Overlay explicit bindings (always win)
for (key, secret_ref) in &self.config.secrets {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider)
&& let Some(value) = source.resolve_ref(key, secret_ref)?
{
map.insert(key.clone(), value);
}
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Provider, SecretRef, SecretsConfig};
fn config_with_env_only() -> SecretsConfig {
SecretsConfig {
providers: vec![Provider::Env],
provider: HashMap::new(),
secrets: HashMap::new(),
}
}
#[test]
fn resolver_from_config_env_only() {
let resolver = SecretResolver::from_config(config_with_env_only());
assert!(resolver.is_ok());
}
#[test]
fn resolver_resolve_env_var() {
unsafe { std::env::set_var("NOTSECRETS_RESOLVER_TEST", "hello") };
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let val = resolver.resolve("NOTSECRETS_RESOLVER_TEST").unwrap();
assert_eq!(val, Some("hello".to_string()));
unsafe { std::env::remove_var("NOTSECRETS_RESOLVER_TEST") };
}
#[test]
fn resolver_resolve_missing_returns_none() {
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let val = resolver.resolve("NOTSECRETS_NONEXISTENT_99999").unwrap();
assert_eq!(val, None);
}
#[test]
fn resolver_binding_overrides_chain() {
unsafe { std::env::set_var("NOTSECRETS_BOUND_TEST", "from_env") };
let mut config = config_with_env_only();
config
.secrets
.insert("NOTSECRETS_BOUND_TEST".to_string(), SecretRef::Env);
let resolver = SecretResolver::from_config(config).unwrap();
let val = resolver.resolve("NOTSECRETS_BOUND_TEST").unwrap();
assert_eq!(val, Some("from_env".to_string()));
unsafe { std::env::remove_var("NOTSECRETS_BOUND_TEST") };
}
#[test]
fn resolver_resolve_all_includes_env() {
unsafe { std::env::set_var("NOTSECRETS_ALL_TEST", "present") };
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let map = resolver.resolve_all().unwrap();
assert_eq!(
map.get("NOTSECRETS_ALL_TEST").map(|s| s.as_str()),
Some("present")
);
unsafe { std::env::remove_var("NOTSECRETS_ALL_TEST") };
}
#[test]
fn resolver_binding_refs_unknown_provider_errors() {
let config = SecretsConfig {
providers: vec![Provider::Env],
provider: HashMap::new(),
secrets: HashMap::from([(
"KEY".to_string(),
SecretRef::Op {
uri: "op://x/y/z".to_string(),
},
)]),
};
let result = SecretResolver::from_config(config);
assert!(result.is_err());
}
}

View File

@@ -1,7 +1,8 @@
use crate::error::AgeError; use crate::config::Provider;
use crate::error::{AgeError, SecretsError};
use crate::identities::Identity; use crate::identities::Identity;
use crate::identities::x25519::X25519Identity; use crate::identities::x25519::X25519Identity;
use crate::ports::IdentitySource; use crate::ports::{IdentitySource, SecretSource};
use std::io::Write; use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -24,7 +25,7 @@ impl BitwardenSource {
.unwrap_or(false); .unwrap_or(false);
if !bw_ok { if !bw_ok {
return Err(AgeError::SourceError { return Err(AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw CLI not found in PATH"), source: anyhow::anyhow!("bw CLI not found in PATH"),
}); });
} }
@@ -34,7 +35,7 @@ impl BitwardenSource {
let password = let password =
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| { rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
AgeError::SourceError { AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("could not read password: {e}"), source: anyhow::anyhow!("could not read password: {e}"),
} }
})?; })?;
@@ -46,26 +47,26 @@ impl BitwardenSource {
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock spawn: {e}"), source: anyhow::anyhow!("bw unlock spawn: {e}"),
})?; })?;
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
stdin stdin
.write_all(password.as_bytes()) .write_all(password.as_bytes())
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock stdin write: {e}"), source: anyhow::anyhow!("bw unlock stdin write: {e}"),
})?; })?;
} }
let output = child let output = child
.wait_with_output() .wait_with_output()
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock wait: {e}"), source: anyhow::anyhow!("bw unlock wait: {e}"),
})?; })?;
if !output.status.success() { if !output.status.success() {
return Err(AgeError::SourceError { return Err(AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!( source: anyhow::anyhow!(
"bw unlock failed: {}", "bw unlock failed: {}",
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
@@ -74,7 +75,7 @@ impl BitwardenSource {
} }
String::from_utf8(output.stdout) String::from_utf8(output.stdout)
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"), source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
})? })?
.trim() .trim()
@@ -90,12 +91,12 @@ impl BitwardenSource {
.args(["get", "notes", &self.item_name, "--session", &session]) .args(["get", "notes", &self.item_name, "--session", &session])
.output() .output()
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw get spawn: {e}"), source: anyhow::anyhow!("bw get spawn: {e}"),
})?; })?;
if !output.status.success() { if !output.status.success() {
return Err(AgeError::SourceError { return Err(AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!( source: anyhow::anyhow!(
"bw get notes '{}' failed: {}", "bw get notes '{}' failed: {}",
self.item_name, self.item_name,
@@ -105,14 +106,14 @@ impl BitwardenSource {
} }
let key = String::from_utf8(output.stdout) let key = String::from_utf8(output.stdout)
.map_err(|e| AgeError::SourceError { .map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw output UTF-8: {e}"), source: anyhow::anyhow!("bw output UTF-8: {e}"),
})? })?
.trim() .trim()
.to_string(); .to_string();
if key.is_empty() { if key.is_empty() {
return Err(AgeError::SourceError { return Err(AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name), source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
}); });
} }
@@ -120,6 +121,20 @@ impl BitwardenSource {
} }
} }
impl SecretSource for BitwardenSource {
fn name(&self) -> &str {
"bitwarden"
}
fn provider(&self) -> Provider {
Provider::Bitwarden
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
impl IdentitySource for BitwardenSource { impl IdentitySource for BitwardenSource {
fn name(&self) -> &str { fn name(&self) -> &str {
"bitwarden" "bitwarden"
@@ -129,7 +144,7 @@ impl IdentitySource for BitwardenSource {
let key = self.retrieve_key()?; let key = self.retrieve_key()?;
let identity = let identity =
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError { X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: "bitwarden".to_string(),
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"), source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
})?; })?;
Ok(Box::new(identity)) Ok(Box::new(identity))

View File

@@ -0,0 +1,35 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::SecretSource;
pub struct DirenvSource;
impl SecretSource for DirenvSource {
fn name(&self) -> &str {
"direnv"
}
fn provider(&self) -> Provider {
Provider::Direnv
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direnv_stub_resolve_returns_none() {
assert_eq!(DirenvSource.resolve("ANY").unwrap(), None);
}
#[test]
fn direnv_stub_name_and_provider() {
assert_eq!(DirenvSource.name(), "direnv");
assert_eq!(DirenvSource.provider(), Provider::Direnv);
}
}

View File

@@ -0,0 +1,89 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::{EnumerableSecretSource, SecretSource};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
pub struct DotenvxSource {
env_file: PathBuf,
}
impl DotenvxSource {
pub fn new(env_file: PathBuf) -> Self {
Self { env_file }
}
fn decrypt_all(&self) -> Result<HashMap<String, String>, SecretsError> {
let output = Command::new("dotenvx")
.args([
"run",
"--env-file",
&self.env_file.to_string_lossy(),
"--",
"env",
])
.output()
.map_err(|e| SecretsError::SourceError {
name: "dotenvx".to_string(),
source: e.into(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SecretsError::SourceError {
name: "dotenvx".to_string(),
source: anyhow::anyhow!("dotenvx run failed: {stderr}"),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
let k = k.trim();
if !k.is_empty() && !k.starts_with('#') {
map.insert(k.to_string(), v.to_string());
}
}
}
Ok(map)
}
}
impl SecretSource for DotenvxSource {
fn name(&self) -> &str {
"dotenvx"
}
fn provider(&self) -> Provider {
Provider::Dotenvx
}
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
self.decrypt_all().map(|m| m.get(key).cloned())
}
}
impl EnumerableSecretSource for DotenvxSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
self.decrypt_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotenvx_source_name_and_provider() {
let source = DotenvxSource::new("/tmp/test.env".into());
assert_eq!(source.name(), "dotenvx");
assert_eq!(source.provider(), Provider::Dotenvx);
}
#[test]
fn dotenvx_source_resolve_missing_file_returns_error() {
let source = DotenvxSource::new("/nonexistent/.env".into());
let result = source.resolve_all();
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,35 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::SecretSource;
pub struct DotenvySource;
impl SecretSource for DotenvySource {
fn name(&self) -> &str {
"dotenvy"
}
fn provider(&self) -> Provider {
Provider::Dotenvy
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotenvy_stub_resolve_returns_none() {
assert_eq!(DotenvySource.resolve("ANY").unwrap(), None);
}
#[test]
fn dotenvy_stub_name_and_provider() {
assert_eq!(DotenvySource.name(), "dotenvy");
assert_eq!(DotenvySource.provider(), Provider::Dotenvy);
}
}

View File

@@ -0,0 +1,62 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::{EnumerableSecretSource, SecretSource};
use std::collections::HashMap;
pub struct EnvSource;
impl SecretSource for EnvSource {
fn name(&self) -> &str {
"env"
}
fn provider(&self) -> Provider {
Provider::Env
}
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
Ok(std::env::var(key).ok())
}
}
impl EnumerableSecretSource for EnvSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
Ok(std::env::vars().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_source_resolve_existing_key() {
// SAFETY: test is single-threaded
unsafe { std::env::set_var("NOTSECRETS_TEST_KEY", "test_value") };
let source = EnvSource;
let result = source.resolve("NOTSECRETS_TEST_KEY").unwrap();
assert_eq!(result, Some("test_value".to_string()));
unsafe { std::env::remove_var("NOTSECRETS_TEST_KEY") };
}
#[test]
fn env_source_resolve_missing_key() {
let source = EnvSource;
let result = source.resolve("NOTSECRETS_NONEXISTENT_KEY_12345").unwrap();
assert_eq!(result, None);
}
#[test]
fn env_source_resolve_all_contains_home_or_user() {
let source = EnvSource;
let map = source.resolve_all().unwrap();
assert!(map.contains_key("HOME") || map.contains_key("USER"));
}
#[test]
fn env_source_name_and_provider() {
let source = EnvSource;
assert_eq!(source.name(), "env");
assert_eq!(source.provider(), Provider::Env);
}
}

View File

@@ -0,0 +1,92 @@
use crate::config::{Provider, SecretRef};
use crate::error::SecretsError;
use crate::ports::SecretSource;
use std::process::Command;
pub struct GsmSource {
project: String,
}
impl GsmSource {
pub fn new(project: String) -> Self {
Self { project }
}
}
impl SecretSource for GsmSource {
fn name(&self) -> &str {
"gsm"
}
fn provider(&self) -> Provider {
Provider::Gsm
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
fn resolve_ref(
&self,
key: &str,
secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
let SecretRef::Gsm { path } = secret_ref else {
return self.resolve(key);
};
let output = Command::new("gcloud")
.args([
"secrets",
"versions",
"access",
"latest",
"--secret",
path,
"--project",
&self.project,
])
.output()
.map_err(|e| SecretsError::SourceError {
name: "gsm".to_string(),
source: e.into(),
})?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretsError::SourceError {
name: "gsm".to_string(),
source: anyhow::anyhow!("gcloud secrets failed: {stderr}"),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gsm_source_name_and_provider() {
let source = GsmSource::new("my-project".to_string());
assert_eq!(source.name(), "gsm");
assert_eq!(source.provider(), Provider::Gsm);
}
#[test]
fn gsm_source_resolve_without_ref_returns_none() {
let source = GsmSource::new("my-project".to_string());
let result = source.resolve("KEY").unwrap();
assert_eq!(result, None);
}
#[test]
fn gsm_source_resolve_ref_wrong_variant_delegates() {
let source = GsmSource::new("my-project".to_string());
let wrong_ref = SecretRef::Env;
let result = source.resolve_ref("KEY", &wrong_ref).unwrap();
assert_eq!(result, None);
}
}

View File

@@ -0,0 +1,35 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::SecretSource;
pub struct MiseSource;
impl SecretSource for MiseSource {
fn name(&self) -> &str {
"mise"
}
fn provider(&self) -> Provider {
Provider::Mise
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mise_stub_resolve_returns_none() {
assert_eq!(MiseSource.resolve("ANY").unwrap(), None);
}
#[test]
fn mise_stub_name_and_provider() {
assert_eq!(MiseSource.name(), "mise");
assert_eq!(MiseSource.provider(), Provider::Mise);
}
}

View File

@@ -1,8 +1,30 @@
pub mod bitwarden; pub mod bitwarden;
pub mod direnv;
pub mod dotenvx;
pub mod dotenvy;
pub mod env;
pub mod file; pub mod file;
pub mod gsm;
pub mod mise;
pub mod nuenv;
pub mod op;
pub mod prompt; pub mod prompt;
pub mod sops;
pub mod vault;
pub mod yubikey;
pub use crate::ports::IdentitySource; pub use crate::ports::IdentitySource;
pub use bitwarden::BitwardenSource; pub use bitwarden::BitwardenSource;
pub use direnv::DirenvSource;
pub use dotenvx::DotenvxSource;
pub use dotenvy::DotenvySource;
pub use env::EnvSource;
pub use file::FileSource; pub use file::FileSource;
pub use gsm::GsmSource;
pub use mise::MiseSource;
pub use nuenv::NuenvSource;
pub use op::OpSource;
pub use prompt::PromptSource; pub use prompt::PromptSource;
pub use sops::SopsSource;
pub use vault::VaultSource;
pub use yubikey::YubikeySource;

View File

@@ -0,0 +1,37 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::SecretSource;
pub struct NuenvSource;
impl SecretSource for NuenvSource {
fn name(&self) -> &str {
"nuenv"
}
fn provider(&self) -> Provider {
Provider::Nuenv
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nuenv_stub_resolve_returns_none() {
let source = NuenvSource;
assert_eq!(source.resolve("ANY").unwrap(), None);
}
#[test]
fn nuenv_stub_name_and_provider() {
let source = NuenvSource;
assert_eq!(source.name(), "nuenv");
assert_eq!(source.provider(), Provider::Nuenv);
}
}

View File

@@ -0,0 +1,84 @@
use crate::config::{Provider, SecretRef};
use crate::error::SecretsError;
use crate::ports::SecretSource;
use std::process::Command;
pub struct OpSource {
account: String,
}
impl OpSource {
pub fn new(account: String) -> Self {
Self { account }
}
}
impl SecretSource for OpSource {
fn name(&self) -> &str {
"op"
}
fn provider(&self) -> Provider {
Provider::Op
}
/// OpSource cannot resolve by key name alone -- requires a ref.
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
fn resolve_ref(
&self,
key: &str,
secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
let SecretRef::Op { uri } = secret_ref else {
return self.resolve(key);
};
let output = Command::new("op")
.args(["read", uri, "--account", &self.account])
.output()
.map_err(|e| SecretsError::SourceError {
name: "op".to_string(),
source: e.into(),
})?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretsError::SourceError {
name: "op".to_string(),
source: anyhow::anyhow!("op read failed: {stderr}"),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn op_source_name_and_provider() {
let source = OpSource::new("my.1password.com".to_string());
assert_eq!(source.name(), "op");
assert_eq!(source.provider(), Provider::Op);
}
#[test]
fn op_source_resolve_without_ref_returns_none() {
let source = OpSource::new("my.1password.com".to_string());
let result = source.resolve("SOME_KEY").unwrap();
assert_eq!(result, None);
}
#[test]
fn op_source_resolve_ref_wrong_variant_delegates() {
let source = OpSource::new("my.1password.com".to_string());
let wrong_ref = SecretRef::Env;
let result = source.resolve_ref("KEY", &wrong_ref).unwrap();
assert_eq!(result, None);
}
}

View File

@@ -0,0 +1,83 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::{EnumerableSecretSource, SecretSource};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
pub struct SopsSource {
file: PathBuf,
}
impl SopsSource {
pub fn new(file: PathBuf) -> Self {
Self { file }
}
fn decrypt_all(&self) -> Result<HashMap<String, String>, SecretsError> {
let output = Command::new("sops")
.args(["--decrypt", &self.file.to_string_lossy()])
.output()
.map_err(|e| SecretsError::SourceError {
name: "sops".to_string(),
source: e.into(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SecretsError::SourceError {
name: "sops".to_string(),
source: anyhow::anyhow!("sops decrypt failed: {stderr}"),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
let k = k.trim();
if !k.is_empty() && !k.starts_with('#') {
map.insert(k.to_string(), v.to_string());
}
}
}
Ok(map)
}
}
impl SecretSource for SopsSource {
fn name(&self) -> &str {
"sops"
}
fn provider(&self) -> Provider {
Provider::Sops
}
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
self.decrypt_all().map(|m| m.get(key).cloned())
}
}
impl EnumerableSecretSource for SopsSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
self.decrypt_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sops_source_name_and_provider() {
let source = SopsSource::new("/tmp/secrets.sops.env".into());
assert_eq!(source.name(), "sops");
assert_eq!(source.provider(), Provider::Sops);
}
#[test]
fn sops_source_resolve_missing_file_returns_error() {
let source = SopsSource::new("/nonexistent/secrets.sops.env".into());
let result = source.resolve("KEY");
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,35 @@
use crate::config::Provider;
use crate::error::SecretsError;
use crate::ports::SecretSource;
pub struct VaultSource;
impl SecretSource for VaultSource {
fn name(&self) -> &str {
"vault"
}
fn provider(&self) -> Provider {
Provider::Vault
}
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vault_stub_resolve_returns_none() {
assert_eq!(VaultSource.resolve("ANY").unwrap(), None);
}
#[test]
fn vault_stub_name_and_provider() {
assert_eq!(VaultSource.name(), "vault");
assert_eq!(VaultSource.provider(), Provider::Vault);
}
}

View File

@@ -0,0 +1,72 @@
use crate::error::AgeError;
use crate::identities::Identity;
use crate::ports::IdentitySource;
/// Reads an age private key from YubiKey PIV slot 9c (Digital Signature slot).
///
/// Requires the `yubikey` feature to be enabled. When the feature is absent this
/// source is compiled out entirely — the struct still exists for type-level use but
/// `load()` always returns an error.
pub struct YubikeySource;
impl IdentitySource for YubikeySource {
fn name(&self) -> &str {
"yubikey-piv-9c"
}
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
load_impl()
}
}
#[cfg(feature = "yubikey")]
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
use crate::identities::x25519::X25519Identity;
use yubikey::{YubiKey, piv};
let mut yk = YubiKey::open().map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("cannot open YubiKey: {e}"),
})?;
// Slot 9c — Digital Signature slot, used here to store the age private key.
let data =
piv::read_object(&mut yk, piv::ObjectId::from(piv::SlotId::Signature)).map_err(|e| {
AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("cannot read PIV slot 9c: {e}"),
}
})?;
let key = std::str::from_utf8(&data)
.map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("PIV slot 9c data is not valid UTF-8: {e}"),
})?
.trim()
.to_string();
if key.is_empty() {
return Err(AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("PIV slot 9c is empty"),
});
}
let identity = X25519Identity::from_bech32(&key).map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("slot 9c content is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
}
#[cfg(not(feature = "yubikey"))]
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
Err(AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!(
"notsecrets was compiled without the `yubikey` feature; \
YubikeySource is unavailable"
),
})
}

View File

@@ -0,0 +1,82 @@
use notsecrets::ports::{EnumerableSecretSource, SecretSource};
use notsecrets::sources::*;
fn assert_secret_source_contract(source: &dyn SecretSource) {
assert!(
!source.name().is_empty(),
"{:?}: name() must be non-empty",
source.provider()
);
// resolve for unknown key returns Ok(None)
let result = source.resolve("__CONFORMANCE_NONEXISTENT_KEY_99999__");
assert!(
result.is_ok(),
"{}: resolve(unknown) should be Ok, got {:?}",
source.name(),
result,
);
assert_eq!(
result.unwrap(),
None,
"{}: resolve(unknown) should be None",
source.name(),
);
}
fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) {
assert_secret_source_contract(source);
let map = source.resolve_all();
assert!(
map.is_ok(),
"{}: resolve_all() should be Ok, got {:?}",
source.name(),
map,
);
}
#[test]
fn conformance_env_source() {
assert_enumerable_contract(&EnvSource);
}
#[test]
fn conformance_op_source() {
assert_secret_source_contract(&OpSource::new("test.1password.com".to_string()));
}
#[test]
fn conformance_gsm_source() {
assert_secret_source_contract(&GsmSource::new("test-project".to_string()));
}
#[test]
fn conformance_nuenv_stub() {
assert_secret_source_contract(&NuenvSource);
}
#[test]
fn conformance_direnv_stub() {
assert_secret_source_contract(&DirenvSource);
}
#[test]
fn conformance_mise_stub() {
assert_secret_source_contract(&MiseSource);
}
#[test]
fn conformance_vault_stub() {
assert_secret_source_contract(&VaultSource);
}
#[test]
fn conformance_dotenvy_stub() {
assert_secret_source_contract(&DotenvySource);
}
#[test]
fn conformance_bitwarden_stub() {
assert_secret_source_contract(&BitwardenSource::new("test-item"));
}

View File

@@ -18,6 +18,7 @@ notcore = { workspace = true }
notfiles = { workspace = true } notfiles = { workspace = true }
notsecrets = { workspace = true } notsecrets = { workspace = true }
nothooks = { workspace = true } nothooks = { workspace = true }
notnet = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
which = { workspace = true } which = { workspace = true }

View File

@@ -2,27 +2,32 @@ use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus}; use notcore::{HookPhase, Report, StepStatus};
use notfiles::{LinkOptions, link}; use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase}; use nothooks::{HookRunner, run_phase};
use notsecrets::identities::Identity;
use notsecrets::sources::IdentitySource; use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities}; use notsecrets::{
BitwardenSource, FileSource, PromptSource, SecretResolver, YubikeySource, resolve_identities,
};
use serde::Deserialize; use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
pub mod prereqs; pub mod prereqs;
pub mod repo; pub mod repo;
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>; pub use notnet::TailscaleOptions;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NotstrapConfig { pub struct NotstrapConfig {
pub bootstrap: BootstrapSection, pub bootstrap: BootstrapSection,
/// When present, notstrap joins the tailnet before cloning dotfiles.
pub tailscale: Option<TailscaleOptions>,
#[serde(default)] #[serde(default)]
pub hooks: Vec<notcore::HookSpec>, pub hooks: Vec<notcore::HookSpec>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct BootstrapSection { pub struct BootstrapSection {
pub dotfiles_repo: String, /// Required when `[tailscale]` is absent. When `[tailscale]` is present,
/// `gitea_url` from that section is used as the clone URL instead.
pub dotfiles_repo: Option<String>,
pub dotfiles_dir: String, pub dotfiles_dir: String,
#[serde(default = "default_bw_item")] #[serde(default = "default_bw_item")]
pub bw_age_item: String, pub bw_age_item: String,
@@ -42,10 +47,16 @@ pub struct BootstrapOptions {
pub force: bool, pub force: bool,
pub key_file: Option<PathBuf>, pub key_file: Option<PathBuf>,
pub dotfiles: Option<PathBuf>, pub dotfiles: Option<PathBuf>,
/// Override the Tailscale options from the config file. `None` here defers to the
/// config; use `Some(None)` to explicitly skip Tailscale even when the config has
/// a `[tailscale]` section (useful in tests and CI).
///
/// Encoding: `None` = use config, `Some(None)` = skip, `Some(Some(opts))` = override.
pub tailscale: Option<Option<TailscaleOptions>>,
/// None = skip prereq check (tests). Some(f) = run f(). /// None = skip prereq check (tests). Some(f) = run f().
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>, pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
/// None = skip env injection (tests). Some(f) = decrypt sops at path and inject. /// Path to notsecrets.toml. None = skip secret resolution.
pub env_injector: Option<EnvInjector>, pub secrets_config: Option<PathBuf>,
} }
pub fn run(opts: BootstrapOptions) -> Result<Report> { pub fn run(opts: BootstrapOptions) -> Result<Report> {
@@ -76,8 +87,37 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
})?, })?,
}; };
// 3. Clone dotfiles if missing // Resolve which Tailscale options to use: CLI override > config > None.
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) { let ts_opts: Option<TailscaleOptions> = match opts.tailscale {
Some(override_val) => override_val, // Some(None) = skip, Some(Some(o)) = use override
None => cfg.tailscale.clone(), // defer to config
};
// Resolve dotfiles clone URL: Tailscale gitea_url takes precedence over dotfiles_repo.
let clone_url: Option<String> = ts_opts
.as_ref()
.map(|ts| ts.gitea_url.clone())
.or_else(|| cfg.bootstrap.dotfiles_repo.clone());
// 3. Tailscale (inserted before clone, skipped when ts_opts is None)
if let Some(ref ts) = ts_opts {
match notnet::ensure_connected(ts) {
Ok(true) => report.add("tailscale", StepStatus::Ok),
Ok(false) => report.add("tailscale", StepStatus::Skipped),
Err(e) => {
report.add("tailscale", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
}
// 4. Clone dotfiles if missing
let clone_url = clone_url.ok_or_else(|| {
anyhow::anyhow!(
"no dotfiles clone URL: set [bootstrap].dotfiles_repo or add a [tailscale] section"
)
})?;
match repo::clone_if_missing(&clone_url, &dotfiles_dir) {
Ok(true) => { Ok(true) => {
report.add("clone dotfiles", StepStatus::Ok); report.add("clone dotfiles", StepStatus::Ok);
} }
@@ -90,17 +130,18 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
} }
} }
// 4. Resolve age identities // 5. Resolve age identities
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file { let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
vec![Box::new(FileSource::new(kf))] vec![Box::new(FileSource::new(kf))]
} else { } else {
vec![ vec![
Box::new(YubikeySource),
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
Box::new(PromptSource), Box::new(PromptSource),
] ]
}; };
let identities = match resolve_identities(sources) { let _identities = match resolve_identities(sources) {
Ok(ids) => { Ok(ids) => {
report.add("age key", StepStatus::Ok); report.add("age key", StepStatus::Ok);
ids ids
@@ -111,33 +152,44 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
} }
}; };
// 5. Decrypt sops secrets (optional) // 5b. Resolve secrets via notsecrets.toml (optional)
if let Some(injector) = opts.env_injector { if let Some(secrets_path) = opts.secrets_config {
let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); match notsecrets::load_config(&secrets_path) {
match injector(&sops_path, identities) { Ok(secrets_cfg) => match SecretResolver::from_config(secrets_cfg) {
Ok(env_content) => { Ok(resolver) => match resolver.resolve_all() {
for line in env_content.lines() { Ok(env_map) => {
match parse_env_line(line) { for (k, v) in &env_map {
match parse_env_line(&format!("{k}={v}")) {
Ok(Some((k, v))) => { Ok(Some((k, v))) => {
// SAFETY: `notstrap` is a single-threaded bootstrap binary; no other // SAFETY: `notstrap` is a single-threaded bootstrap
// threads exist. Subsequent Command::spawn calls (git, hooks) will // binary; no other threads exist. Keys are validated
// inherit these vars — keys are validated by parse_env_line before // by parse_env_line (null bytes and dangerous keys
// reaching this point (null bytes and dangerous keys are rejected). // are rejected).
unsafe { unsafe {
std::env::set_var(&k, &v); std::env::set_var(&k, &v);
} }
} }
Ok(None) => {} Ok(None) => {}
Err(e) => { Err(e) => {
report.add("decrypt secrets", StepStatus::Failed(e.to_string())); report.add("secrets", StepStatus::Failed(e.to_string()));
return Ok(report); return Ok(report);
} }
} }
} }
report.add("decrypt secrets", StepStatus::Ok); report.add("secrets", StepStatus::Ok);
} }
Err(e) => { Err(e) => {
report.add("decrypt secrets", StepStatus::Failed(e.to_string())); report.add("secrets", StepStatus::Failed(e.to_string()));
return Ok(report);
}
},
Err(e) => {
report.add("secrets", StepStatus::Failed(e.to_string()));
return Ok(report);
}
},
Err(e) => {
report.add("secrets", StepStatus::Failed(e.to_string()));
return Ok(report); return Ok(report);
} }
} }

View File

@@ -44,8 +44,9 @@ fn main() -> Result<()> {
force, force,
key_file, key_file,
dotfiles, dotfiles,
tailscale: None, // defer to [tailscale] section in config (if present)
check_prereqs: Some(Box::new(prereqs::check_prerequisites)), check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
env_injector: None, secrets_config: None,
}; };
let report = run(opts)?; let report = run(opts)?;
report.print(); report.print();

BIN
docs/superpowers/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
# Tailscale Integration Design
**Date:** 2026-04-16
**Status:** Approved
## Goal
Make notfiles environments ephemeral but reliable. A fresh machine runs `notstrap` and gets a
fully configured environment — no public GitHub exposure for dotfiles, no manual Tailscale setup,
no cloud dependency for secrets on bootstrap day.
## Overview
Add a `notnet` crate that owns Tailscale network presence, extend `notsecrets` with a
`YubikeySource`, and thread both into `notstrap`'s orchestration sequence. The dotfiles repo lives
on a private Gitea instance on minibox, reachable only over Tailscale.
## Crate Responsibilities
### `notnet` (new)
Owns all Tailscale concerns:
- Detect if Tailscale is already running (`tailscale status`)
- Install Tailscale if missing (package manager detection: `apt`/`brew`/`pacman`, fallback to
official install script)
- Resolve the auth key via priority chain (see below)
- Join the tailnet (`tailscale up --authkey=<key>`)
- Verify the target peer is reachable (ping by Tailscale hostname)
**Auth key resolution chain:**
1. `TS_AUTHKEY` env var
2. YubiKey PIV slot 9d (via `yubikey` crate)
3. Interactive prompt
**Public API:**
```rust
pub struct TailscaleOptions {
pub peer_hostname: String,
pub gitea_url: String,
pub install: bool,
}
/// Ensure this machine is connected to the tailnet and the target peer is reachable.
/// Returns immediately if Tailscale is already running.
pub fn ensure_connected(opts: &TailscaleOptions) -> Result<()>;
```
### `notsecrets` (extended)
New `YubikeySource` implementing `IdentitySource`:
- Uses the `yubikey` crate (pure Rust, no `ykman` dependency)
- Opens the first connected YubiKey
- Reads age private key material from PIV slot 9c
- Returns `Box<dyn Identity>`
Updated source resolution order in notstrap:
1. `--key-file` CLI arg
2. `YubikeySource` (PIV slot 9c)
3. `BitwardenSource`
4. `PromptSource`
### `notstrap` (updated)
`BootstrapOptions` gains:
```rust
pub tailscale: Option<TailscaleOptions>,
```
`None` = skip notnet entirely (existing machines, tests, CI).
`notstrap.toml` gains a `[tailscale]` section. When present, `gitea_url` is used as the clone
URL instead of `[bootstrap].dotfiles_repo`.
## YubiKey PIV Slot Layout
| Slot | Purpose | Managed by |
|------|----------------------------------|-------------|
| 9a | SSH authentication | 1Password |
| 9c | Age private key (dotfiles/SOPS) | notsecrets |
| 9d | Tailscale auth key | notnet |
Slot 9a is untouched — 1Password agent remains the primary SSH auth for day-to-day use.
## Configuration
`notstrap.toml` example with Tailscale enabled:
```toml
[bootstrap]
dotfiles_dir = "~/dotfiles"
bw_age_item = "age-key-dotfiles"
sops_file = "secrets/bootstrap.sops.env"
# dotfiles_repo is optional when [tailscale] is present
[tailscale]
peer_hostname = "minibox"
gitea_url = "http://minibox:3000/joe/dotfiles.git"
install = true
```
## Full Bootstrap Sequence
| Step | Crate | Skip condition |
|-------------------|---------------|---------------------------------------|
| 1. Prerequisites | notstrap | — |
| 2. Tailscale | notnet | Already on tailnet |
| 3. Clone dotfiles | notstrap/repo | Already cloned |
| 4. Age key | notsecrets | — |
| 5. Decrypt SOPS | notstrap | No injector configured |
| 6. Link dotfiles | notfiles | — |
| 7. Dot hooks | nothooks | — |
| 8. Setup hooks | nothooks | Already run (state file) |
## Error Handling
- If `TS_AUTHKEY` is unset and no YubiKey is connected and user declines prompt → clear error,
bootstrap aborts at step 2
- If Tailscale installs but peer is unreachable → error naming the peer hostname, bootstrap aborts
- If YubiKey slot 9c is empty → `YubikeySource` returns `Err`, resolver falls through to
`BitwardenSource`
- All steps follow the existing `StepStatus::Failed` / early-return pattern in notstrap
## Dependencies (new)
| Crate | Used by | Purpose |
|-------------|---------------------|-------------------------------|
| `yubikey` | notnet, notsecrets | PIV slot access (pure Rust) |
| `tailscale` | notnet | tailscale-rs SDK |
## Out of Scope
- Multi-YubiKey support (first connected device wins)
- Tailscale exit node or subnet router configuration
- Rotating auth keys automatically
- Windows support

View File

@@ -0,0 +1,383 @@
# notsecrets Multi-Provider Secret Resolution
**Date:** 2026-06-06
**Crate:** `notsecrets`
**Status:** Approved design, pending implementation plan
## Goal
Extend notsecrets with a general-purpose secret/env resolution system alongside
the existing age identity system. Multiple backends resolve arbitrary secrets
(API keys, tokens, env vars) via a typed provider chain with explicit bindings.
## Non-Goals
- Replacing the existing `IdentitySource` / age identity system
- Runtime hot-reloading of config
- Secret rotation or lease management
- Encrypting secrets (write path) -- read-only resolution
## Architecture
### Two independent port systems in one crate
```
notsecrets
+-- Age identity system (existing, untouched)
| IdentitySource trait -> BitwardenSource, FileSource, PromptSource, YubikeySource
| resolve_identities() -> Vec<Box<dyn Identity>>
|
+-- Secret resolution system (new)
SecretSource trait -> 11 provider adapters
SecretResolver -> config-driven resolution with bindings
```
### Core Traits
```rust
/// Port: resolve a single secret by key name.
pub trait SecretSource {
fn name(&self) -> &str;
fn provider(&self) -> Provider;
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
/// Resolve using a provider-specific typed reference.
/// Default: ignores ref, falls back to resolve(key).
fn resolve_ref(
&self,
key: &str,
secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
let _ = secret_ref;
self.resolve(key)
}
}
/// Extended port: sources that can enumerate all available secrets.
pub trait EnumerableSecretSource: SecretSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
}
```
ISP rationale: not every backend supports enumeration. `OpSource` and `GsmSource`
require explicit refs for lookup -- they cannot enumerate a vault without extra
config. Forcing `resolve_all()` on every impl would produce incomplete maps.
### Type System
#### Provider enum
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Env,
Op,
Dotenvx,
Sops,
Gsm,
Nuenv,
Direnv,
Mise,
Bitwarden,
Vault,
Dotenvy,
}
```
#### Provider config (tagged enum)
```rust
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ProviderConfig {
Env,
Op { account: String },
Dotenvx { env_file: PathBuf },
Sops { file: PathBuf },
Gsm { project: String },
Nuenv,
Direnv,
Mise,
Bitwarden { server_url: Option<String> },
Vault { addr: String, mount: Option<String> },
Dotenvy { path: PathBuf },
}
```
#### Secret references (tagged enum)
```rust
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum SecretRef {
Env,
Op { uri: String },
Dotenvx { key: Option<String> },
Sops { key: Option<String> },
Gsm { path: String },
Nuenv { key: Option<String> },
Direnv { key: Option<String> },
Mise { key: Option<String> },
Bitwarden { item: String, field: Option<String> },
Vault { path: String, field: Option<String> },
Dotenvy { key: Option<String> },
}
impl SecretRef {
pub fn provider(&self) -> Provider {
match self {
Self::Env { .. } => Provider::Env,
Self::Op { .. } => Provider::Op,
Self::Dotenvx { .. } => Provider::Dotenvx,
Self::Sops { .. } => Provider::Sops,
Self::Gsm { .. } => Provider::Gsm,
Self::Nuenv { .. } => Provider::Nuenv,
Self::Direnv { .. } => Provider::Direnv,
Self::Mise { .. } => Provider::Mise,
Self::Bitwarden { .. } => Provider::Bitwarden,
Self::Vault { .. } => Provider::Vault,
Self::Dotenvy { .. } => Provider::Dotenvy,
}
}
}
```
#### Config root
```rust
#[derive(Debug, Deserialize)]
pub struct SecretsConfig {
pub providers: Vec<Provider>,
#[serde(default)]
pub provider: HashMap<Provider, ProviderConfig>,
#[serde(default)]
pub secrets: HashMap<String, SecretRef>,
}
```
### Compile-Time Guarantees
- Misspelled provider name in TOML -> serde deserialization error
- Binding with wrong fields for a provider -> serde error
- `from_config` validates every binding references a provider in the chain
(enum comparison, not string matching)
- `resolve_ref` receives typed variant; compiler warns on missing arms
### Domain Error
```rust
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("[{name}] {source}")]
SourceError { name: String, source: anyhow::Error },
#[error("no provider resolved key: {key}")]
NotFound { key: String },
#[error("config error: {0}")]
Config(String),
}
```
Adapters map infra errors (reqwest, tonic, serde, io) into
`SecretsError::SourceError`. Domain logic never sees infra error types.
### SecretResolver (domain logic)
```rust
pub struct SecretResolver {
config: SecretsConfig,
sources: Vec<Box<dyn SecretSource>>,
enumerable: Vec<Box<dyn EnumerableSecretSource>>,
}
impl SecretResolver {
pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError>;
pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
}
```
#### resolve(key) flow
1. Check explicit bindings (`config.secrets`). If key has a `SecretRef`,
route to the named source via `source.resolve_ref(key, &secret_ref)`.
2. Otherwise, walk the priority chain (`sources` in order).
First `Some` wins.
#### resolve_all() flow
1. Iterate enumerable sources in priority order. Merge maps -- earlier
source wins on key collision.
2. Overlay explicit bindings: resolve each bound key individually via
`resolve_ref`. Bindings always override chain results.
### No inject_env()
SRP: env injection (`set_var`) is a side effect belonging to the consumer
(notstrap), not the resolver. `SecretResolver` returns data; the caller
decides what to do with it.
## Config File
Separate `notsecrets.toml` at the dotfiles root. Rationale:
- notsecrets is consumed by notstrap, notfiles, and potentially standalone
- Provider chain config is complex enough to warrant its own file
- Follows existing pattern (each crate has its own state file)
- Enables standalone CLI usage: `notsecrets resolve DATABASE_URL`
### Example
```toml
providers = ["env", "op", "dotenvx", "sops", "gsm"]
[provider.op]
type = "op"
account = "my.1password.com"
[provider.dotenvx]
type = "dotenvx"
env_file = "~/dev/.env"
[provider.sops]
type = "sops"
file = "secrets/bootstrap.sops.env"
[provider.gsm]
type = "gsm"
project = "my-gcp-project"
[secrets]
DATABASE_URL = { source = "op", uri = "op://Personal/db/url" }
ANTHROPIC_API_KEY = { source = "dotenvx" }
GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" }
```
## File Layout
New and modified files in `crates/notsecrets/src/`:
```
src/
lib.rs # add re-exports for new public API
ports.rs # add SecretSource, EnumerableSecretSource
config.rs # NEW -- SecretsConfig, ProviderConfig, SecretRef, Provider, load_config()
resolver.rs # NEW -- SecretResolver
error.rs # add SecretsError alongside existing AgeError
sources/
mod.rs # add new source re-exports
env.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource
op.rs # NEW tier 1 -- SecretSource only
dotenvx.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource
sops.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource
gsm.rs # NEW tier 1 -- SecretSource only
nuenv.rs # NEW tier 2 stub
direnv.rs # NEW tier 2 stub
mise.rs # NEW tier 2 stub
vault.rs # NEW tier 2 stub
dotenvy.rs # NEW tier 2 stub
bitwarden.rs # existing -- add tier 2 SecretSource stub
file.rs # existing -- untouched
prompt.rs # existing -- untouched
yubikey.rs # existing -- untouched
identities/ # untouched
recipients/ # untouched
decrypt.rs # untouched
encrypt.rs # untouched
format.rs # untouched
```
## Consumer Changes
### notstrap
Current hardcoded source chain (`lib.rs:135-143`) and `EnvInjector` closure
replaced by:
```rust
let secrets_config = notsecrets::config::load_config(
&dotfiles_dir.join("notsecrets.toml")
)?;
let resolver = SecretResolver::from_config(secrets_config)?;
let env_map = resolver.resolve_all()?;
for (k, v) in &env_map {
if let Some((k, v)) = parse_env_line(&format!("{k}={v}"))? {
unsafe { std::env::set_var(&k, &v); }
}
}
report.add("secrets", StepStatus::Ok);
```
`EnvInjector` type alias and `env_injector` field on `BootstrapOptions` become
unnecessary and are removed.
## Testing Strategy
| Dimension | Scope | When |
| ------------- | --------------------------------------------------- | ----------------------------- |
| Unit | Resolver routing (binding vs chain), config parsing | Every new function |
| Conformance | Shared suite for SecretSource trait, all impls | Every new impl |
| Property | Config deserialization: arbitrary TOML never panics | After config types defined |
| Integration | Resolver + real EnvSource + fake sources | After resolver + 2+ sources |
| Regression | One test per bug | Ongoing |
| Fuzz | Deferred -- no raw byte parsing or unsafe | Revisit if SOPS moves in-crate |
| Model check | Deferred -- no arithmetic invariants | N/A |
### Conformance suite
```rust
fn assert_secret_source_contract(source: &dyn SecretSource) {
// 1. name() returns non-empty string
// 2. provider() matches expected variant
// 3. resolve() for unknown key returns Ok(None), not Err
// 4. resolve_ref() with mismatched variant returns Ok(None) or
// delegates to resolve()
}
fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) {
assert_secret_source_contract(source);
// 5. resolve_all() returns Ok(map)
// 6. For every key in resolve_all() map,
// resolve(key) returns Ok(Some(same_value))
}
```
## Implementation Tiers
### Tier 1 (first milestone)
- Types: `Provider`, `ProviderConfig`, `SecretRef`, `SecretsConfig`, `SecretsError`
- Traits: `SecretSource`, `EnumerableSecretSource`
- Resolver: `SecretResolver` with `from_config`, `resolve`, `resolve_all`
- Sources: `EnvSource`, `OpSource`, `DotenvxSource`, `SopsSource`, `GsmSource`
- Config: `load_config()`, `notsecrets.toml` parsing
- Consumer: notstrap integration
- Tests: unit + conformance + integration
### Tier 2 (subsequent)
- Sources: `NuenvSource`, `DirenvSource`, `MiseSource`, `BitwardenSource`
(SecretSource impl), `VaultSource`, `DotenvySource`
- Each: trait impl returning `Ok(None)` / empty map, passing conformance suite
- Implemented as needed when the backend is required
## Decisions Log
| Decision | Why | Alternative rejected |
| --------------------------------- | ------------------------------------------------------ | ---------------------------------- |
| Separate `notsecrets.toml` | Decouples from notfiles/notstrap config | Subsection in notfiles.toml |
| Two traits (ISP split) | Not all backends can enumerate | Single trait with `resolve_all()` |
| Typed enums over string maps | Compile-time validation, exhaustive matching | `HashMap<String, toml::Value>` |
| No `inject_env()` on resolver | SRP -- side effects belong to consumer | Method on SecretResolver |
| `resolve_ref` default impl | Sources that don't use refs delegate to `resolve(key)` | Per-binding source instances |
| Explicit bindings override chain | User intent is unambiguous when they bind a key | Chain-only, no bindings |
| `SecretsError` separate from `AgeError` | Different domains, different consumers | Unified error enum |
## Out of Scope
- Secret write/rotation path
- RBAC or access control on secrets
- Caching/TTL for resolved secrets
- Async trait (all providers are sync CLI/file-based today)
- Migration tooling from old notstrap config format

7
notfiles.toml Normal file
View File

@@ -0,0 +1,7 @@
[defaults]
target = "~"
ignore = [".git", ".DS_Store", "README.md", "LICENSE*", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml", "Cargo.toml", "Cargo.lock", "crates", "docs", "tests", "target", "scripts", "mise.toml", "deny.toml", "rustqual.toml", ".github", ".gitea", ".envrc", ".sccignore", ".codex", ".superpowers", ".remember", ".worktrees", ".ctx", "HANDOFF.md", "AGENTS.md", "preflight.md"]
include = ["nushell"]
[packages.nushell]

Some files were not shown because too many files have changed in this diff Show More