15 Commits

Author SHA1 Message Date
039f8d808d chore(manifests): sort Cargo.toml dependencies 2026-07-11 22:10:48 -04:00
ca7c035ea5 docs(handoff): log 2026-07-08 — hexagonal architecture refactor, adapters, integration tests, notsecrets cleanup
- Completed hexagonal architecture refactor with adapter pattern
- Added comprehensive integration tests
- Cleaned up notsecrets integration
- Moved config payload to notfiles-config repo

Commits: 6427d18, 25624c9
2026-07-08 15:22:59 -04:00
25624c9f2b chore(nushell): move config payload out of tool repo 2026-07-08 13:28:57 -04:00
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
107 changed files with 6784 additions and 403 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,39 @@
project: notfiles
id: notfile
updated: 2026-04-15
branch: refactor/formalize-ports
state:
tests: passing
build: clean
notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob.
items: []
updated: 2026-07-08
items:
- id: uncommitted-work
doob_uuid: null
name: uncommitted-work
priority: P1
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:
- date: 2026-07-08
summary: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo
commits:
- 6427d18
- 25624c9
- 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
summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
commits:
- 67f20ee
commits: []
- date: 2026-04-15
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
.worktrees/
.claude.local.md
docs/
docs/*
!docs/superpowers/
librust_out.rlib
.remember/
!docs/superpowers/
# 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
This is a Cargo workspace with 5 crates under `crates/`:
This is a Cargo workspace with 7 crates under `crates/`:
| Crate | Purpose |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` |
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) |
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption |
| ------------ | -------------------------------------------------------------------------- |
| `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 |
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
| | heatmap, cycle detection |
## Architecture
### 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/`:
- **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.
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`.
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`.
- **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.
- **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
`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
@@ -63,7 +91,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
## 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

333
Cargo.lock generated
View File

@@ -107,6 +107,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
@@ -272,6 +278,15 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
@@ -342,6 +357,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "crypto-common"
version = "0.1.7"
@@ -387,10 +414,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"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]]
name = "difflib"
version = "0.4.0"
@@ -436,6 +485,20 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ed25519"
version = "2.2.3"
@@ -466,6 +529,27 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "env_home"
version = "0.1.0"
@@ -494,6 +578,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "fiat-crypto"
version = "0.2.9"
@@ -506,6 +600,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -520,6 +620,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -570,6 +671,17 @@ dependencies = [
"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]]
name = "hashbrown"
version = "0.15.5"
@@ -766,6 +878,22 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "notcore"
version = "0.1.0"
@@ -773,6 +901,7 @@ dependencies = [
"anyhow",
"dirs",
"serde",
"strsim",
"thiserror 2.0.18",
"toml",
]
@@ -785,10 +914,12 @@ dependencies = [
"assert_fs",
"chrono",
"clap",
"clap_complete",
"dirs",
"globset",
"notcore",
"serde",
"serde_json",
"tempfile",
"toml",
]
@@ -819,6 +950,17 @@ dependencies = [
"toml",
]
[[package]]
name = "notnet"
version = "0.1.0"
dependencies = [
"anyhow",
"rpassword",
"serde",
"thiserror 2.0.18",
"yubikey",
]
[[package]]
name = "notsecrets"
version = "0.1.0"
@@ -835,12 +977,14 @@ dependencies = [
"notcore",
"rand",
"rpassword",
"rsa",
"scrypt",
"serde",
"sha2",
"tempfile",
"thiserror 2.0.18",
"toml",
"x25519-dalek",
"yubikey",
"zeroize",
]
@@ -853,6 +997,7 @@ dependencies = [
"notcore",
"notfiles",
"nothooks",
"notnet",
"notsecrets",
"serde",
"toml",
@@ -871,6 +1016,7 @@ dependencies = [
"num-iter",
"num-traits",
"rand",
"serde",
"smallvec",
"zeroize",
]
@@ -929,6 +1075,30 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "password-hash"
version = "0.5.0"
@@ -950,6 +1120,25 @@ dependencies = [
"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]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -980,6 +1169,12 @@ dependencies = [
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -1037,6 +1232,15 @@ dependencies = [
"syn",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1063,9 +1267,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
@@ -1119,6 +1323,16 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rpassword"
version = "7.4.0"
@@ -1219,6 +1433,29 @@ dependencies = [
"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]]
name = "semver"
version = "1.0.27"
@@ -1281,6 +1518,17 @@ dependencies = [
"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]]
name = "sha2"
version = "0.10.9"
@@ -1412,6 +1660,27 @@ dependencies = [
"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]]
name = "toml"
version = "0.8.23"
@@ -1487,6 +1756,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "version_check"
version = "0.9.5"
@@ -1892,6 +2172,53 @@ dependencies = [
"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]]
name = "zerocopy"
version = "0.8.48"

View File

@@ -1,5 +1,10 @@
[workspace.package]
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]
members = [
@@ -7,6 +12,7 @@ members = [
"crates/notfiles",
"crates/notsecrets",
"crates/nothooks",
"crates/notnet",
"crates/notstrap",
"crates/notgraph",
"tests/integration",
@@ -16,10 +22,13 @@ resolver = "2"
[workspace.dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "6"
globset = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
strsim = "0.11"
thiserror = "2"
toml = "0.8"
which = "7"
@@ -30,6 +39,7 @@ notcore = { path = "crates/notcore" }
notfiles = { path = "crates/notfiles" }
notsecrets = { path = "crates/notsecrets" }
nothooks = { path = "crates/nothooks" }
notnet = { path = "crates/notnet" }
[profile.release]
opt-level = 3

View File

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

View File

@@ -15,10 +15,24 @@ notfiles link
# Check 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
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):
```bash
@@ -33,11 +47,13 @@ notstrap run
```
notfiles/
├── crates/
│ ├── notcore/ # shared types, config, paths, errors
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
│ ├── 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
── 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
└── notstrap.toml # bootstrap hook/phase config
```
@@ -46,11 +62,13 @@ notfiles/
| Crate | Type | Does |
|-------|------|------|
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates |
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state |
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets |
| `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
| `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
| `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 |
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
### Dependency graph
@@ -60,6 +78,7 @@ notstrap
│ └── notcore
├── notsecrets
│ └── notcore
├── notnet
└── nothooks
└── notcore
```
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
```
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?
└─ linker::link_package() create symlinks (or copies), write state
└─ linker::link_package() create symlinks (or copies), write state,
return LinkResult with counters
```
### State file
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
## Secrets Bootstrap Detail
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order:
`notsecrets` implements a `SecretResolver` with pluggable provider sources:
```
AgeKeySource
├── BitwardenSource bw unlock → session token → bw get item age-key
├── FileSource read from --key-file path (USB, etc.)
── PromptSource read from stdin (paste)
SecretResolver
├── EnvSource read from environment variables
├── OpSource 1Password CLI (op read)
── 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]
method = "symlink"
target = "~"
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
# exclude = ["scratch"] # or blocklist (mutually exclusive)
[packages.secrets]
method = "copy" # copy instead of symlink for sensitive files
[packages.nixos]
platforms = ["linux"] # only link on Linux
[packages.work]
target = "~/work" # different target dir
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"
edition = "2024"
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]
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
dirs = { workspace = true }
anyhow = { workspace = true }
dirs = { workspace = true }
serde = { workspace = true }
strsim = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }

View File

@@ -18,6 +18,14 @@ pub struct Defaults {
pub target: String,
#[serde(default = "default_ignore")]
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 {
@@ -25,6 +33,8 @@ impl Default for Defaults {
Self {
target: default_target(),
ignore: default_ignore(),
include: Vec::new(),
exclude: Vec::new(),
}
}
}
@@ -52,6 +62,10 @@ pub struct PackageConfig {
pub target: Option<String>,
#[serde(default)]
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)]
@@ -100,6 +114,50 @@ impl Config {
.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> {
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
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 {
r#"[defaults]
target = "~"
@@ -138,6 +213,92 @@ mod tests {
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]
fn test_parse_config() {
let toml_str = r#"

View File

@@ -17,6 +17,12 @@ pub enum NotfilesError {
#[error("state file error: {0}")]
State(String),
#[error("glob error: {0}")]
Glob(String),
#[error("validation error: {0}")]
Validation(String),
#[error("{0}")]
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 error;
pub mod paths;
pub mod reporter;
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 paths::{dotfiles_dir, expand_tilde};
pub use reporter::{LinkEvent, Reporter, SilentReporter};
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"
edition = "2024"
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"
[[bin]]
@@ -14,15 +18,17 @@ name = "notfiles"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
clap = { workspace = true }
globset = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
clap_complete = { workspace = true }
dirs = { workspace = true }
globset = { workspace = true }
notcore = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
assert_fs = { workspace = true }
tempfile = { 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 memory;
pub mod reporter;
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)]
pub verbose: bool,
/// Output in JSON format
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
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)]
pub enum Command {
/// Create a starter notfiles.toml
@@ -50,4 +79,31 @@ pub enum Command {
/// Specific packages to check (default: all)
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)
.or_else(|_| Glob::new(&format!("**/{pattern}")))
.map_err(|e| {
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}"))
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
})?;
builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc.
@@ -28,7 +28,7 @@ impl IgnoreMatcher {
}
let globset = builder
.build()
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
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 cli;
pub mod detect;
pub mod ignore;
pub mod linker;
pub mod package;
pub mod ports;
pub mod status;
use anyhow::Result;
use std::path::Path;
use notcore::NotfilesError;
use notcore::reporter::Reporter;
pub use adapters::FileStoreImpl;
pub use linker::{LinkOptions, State};
pub use package::{resolve_packages, resolve_packages_with_store};
pub use linker::{LinkOptions, LinkResult, State};
pub use package::{
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
resolve_packages_with_store,
};
pub use ports::FileStore;
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
/// Link all (or specified) packages using the real filesystem and
/// 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(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
) -> Result<State> {
reporter: &dyn Reporter,
) -> Result<State, NotfilesError> {
let config = notcore::Config::load(dotfiles_dir)?;
config.validate()?;
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 {
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)?;
Ok(state)
}
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
/// Remove managed symlinks/copies for all (or specified) packages.
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(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
) -> Result<()> {
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() {
state
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
packages.to_vec()
};
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)?;
Ok(())

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
use notcore::reporter::{LinkEvent, Reporter};
use notcore::{Config, Method, NotfilesError, expand_tilde};
const STATE_FILE: &str = ".notfiles-state.toml";
@@ -81,6 +82,15 @@ pub struct LinkOptions {
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(
dotfiles_dir: &Path,
config: &Config,
@@ -88,17 +98,23 @@ pub fn link_package(
package: &str,
opts: &LinkOptions,
fs: &dyn FileStore,
) -> Result<(), NotfilesError> {
reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = expand_tilde(config.target_for(package))?;
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 opts.verbose {
println!(" {package}: no files to link");
}
return Ok(());
reporter.report(&LinkEvent::Skip {
source: package,
reason: "no files to link",
});
return Ok(result);
}
for relative in &files {
@@ -108,14 +124,17 @@ pub fn link_package(
// Check if already correctly linked / copied
if is_already_linked(&source, &target, method, fs) {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
}
reporter.report(&LinkEvent::Skip {
source: &source_display,
reason: "already linked",
});
result.skipped += 1;
continue;
}
// 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 {
let _ = state.save(dotfiles_dir, fs);
}
@@ -139,22 +158,20 @@ pub fn link_package(
if !opts.no_backup {
let backup = backup_path(&target);
if opts.dry_run {
println!(
" \x1b[33mwould backup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
reporter.report(&LinkEvent::DryRun {
action: "backup",
source: &source_display,
target: &backup,
});
} else {
if opts.verbose {
println!(
" \x1b[33mbackup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
}
reporter.report(&LinkEvent::Backup {
from: &target,
to: &backup,
});
if let Err(e) = fs.rename(&target, &backup) {
return save_and_return(state, e.into());
}
result.backed_up += 1;
}
} else if !opts.dry_run {
let rm_result = if fs.is_dir(&target) {
@@ -171,9 +188,7 @@ pub fn link_package(
// Create parent directories
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
reporter.report(&LinkEvent::CreateDir { path: parent });
} else if let Err(e) = fs.create_dir_all(parent) {
return save_and_return(state, e.into());
}
@@ -186,12 +201,13 @@ pub fn link_package(
};
if opts.dry_run {
println!(
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
target.display()
);
reporter.report(&LinkEvent::DryRun {
action: action_word,
source: &source_display,
target: &target,
});
} else {
let link_result = match method {
let io_result = match method {
Method::Symlink => {
#[cfg(unix)]
{
@@ -210,14 +226,24 @@ pub fn link_package(
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());
}
if opts.verbose {
println!(
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
target.display()
);
match method {
Method::Symlink => {
reporter.report(&LinkEvent::Link {
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 {
@@ -233,7 +259,7 @@ pub fn link_package(
if !opts.dry_run {
state.save(dotfiles_dir, fs)?;
}
Ok(())
Ok(result)
}
pub fn unlink_package(
@@ -242,6 +268,7 @@ pub fn unlink_package(
package: &str,
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
// 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.
@@ -260,9 +287,10 @@ pub fn unlink_package(
.collect();
if entries.is_empty() {
if opts.verbose {
println!(" {package}: nothing to unlink");
}
reporter.report(&LinkEvent::Skip {
source: package,
reason: "nothing to unlink",
});
return Ok(());
}
@@ -273,9 +301,10 @@ pub fn unlink_package(
let source = PathBuf::from(&entry.source);
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "already gone",
});
if !opts.dry_run {
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
if let Ok(link_target) = fs.read_link(&target) {
if link_target != source {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "symlink points elsewhere",
});
continue;
}
} else {
if opts.verbose {
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "not a symlink",
});
continue;
}
}
Method::Copy => match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
(Ok(_), Ok(_)) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (copied file diverged from source)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "copied file diverged from source",
});
continue;
}
(Err(_), _) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (source missing for copied file)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "source missing for copied file",
});
continue;
}
(_, Err(_)) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (cannot read copied target)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "cannot read copied target",
});
continue;
}
},
}
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 {
if fs.is_dir(&target) {
fs.remove_dir_all(&target)?;
} else {
fs.remove_file(&target)?;
}
if opts.verbose {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
reporter.report(&LinkEvent::Remove { target: &target });
// Clean up empty parent dirs
cleanup_empty_parents(&target, fs);
@@ -362,6 +386,100 @@ pub fn unlink_package(
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 {
match method {
Method::Symlink => {

View File

@@ -1,11 +1,14 @@
use anyhow::{Context, Result};
use clap::Parser;
use clap::{CommandFactory, Parser};
use std::fs;
use notcore::Config;
use notcore::reporter::Reporter;
use notfiles::adapters::{JsonReporter, TerminalReporter};
use notfiles::cli::{Cli, Command};
use notfiles::detect;
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};
fn main() -> Result<()> {
@@ -16,7 +19,24 @@ fn main() -> Result<()> {
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.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 {
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::Link {
force,
@@ -25,8 +45,9 @@ fn main() -> Result<()> {
} => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
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 {
force,
no_backup,
@@ -38,19 +59,26 @@ fn main() -> Result<()> {
println!("\x1b[36m(dry run)\x1b[0m");
}
let mut results = Vec::new();
for pkg in &pkgs {
if cli.verbose || cli.dry_run {
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 {
state.save(&dotfiles_dir, fs)?;
let count: usize = pkgs
.iter()
.map(|p| state.entries_for_package(p).len())
.sum();
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
println!(
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
if count == 1 { "" } else { "s" },
@@ -95,7 +123,7 @@ fn main() -> Result<()> {
if cli.verbose || cli.dry_run {
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 {
@@ -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 } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
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 {
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);
}
}
}
}
Ok(())
}

View File

@@ -5,6 +5,38 @@ use crate::ignore::IgnoreMatcher;
use crate::ports::FileStore;
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).
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
@@ -48,13 +80,28 @@ pub fn resolve_packages_with_store(
requested: &[String],
fs: &dyn FileStore,
) -> 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() {
return Ok(available);
}
for name in requested {
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())
@@ -123,6 +170,40 @@ mod tests {
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]
fn test_resolve_specific_packages() {
let tmp = TempDir::new().unwrap();

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use serde_json::json;
use crate::linker::State;
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
@@ -121,6 +123,126 @@ pub fn package_status(
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]) {
if entries.is_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
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());
}

View File

@@ -0,0 +1,203 @@
use std::sync::{Arc, Mutex};
use notforge::config::RepoSpec;
use notforge::error::NotforgeError;
use notforge::gitea::{
GiteaHttpApi, GiteaHttpRequest, GiteaHttpResponse, GiteaTransport, HttpMethod,
};
use notforge::ports::{ForgeApi, ForgeAuth};
use serde_json::json;
#[derive(Debug, Clone)]
struct RecordedRequest {
method: HttpMethod,
url: String,
authorization: Option<String>,
body: Option<serde_json::Value>,
}
#[derive(Clone)]
struct FakeTransport {
requests: Arc<Mutex<Vec<RecordedRequest>>>,
responses: Arc<Mutex<Vec<Result<GiteaHttpResponse, NotforgeError>>>>,
}
impl FakeTransport {
fn new(responses: Vec<Result<GiteaHttpResponse, NotforgeError>>) -> Self {
Self {
requests: Arc::new(Mutex::new(Vec::new())),
responses: Arc::new(Mutex::new(responses)),
}
}
fn requests(&self) -> Vec<RecordedRequest> {
self.requests.lock().unwrap().clone()
}
}
impl GiteaTransport for FakeTransport {
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError> {
self.requests.lock().unwrap().push(RecordedRequest {
method: request.method,
url: request.url,
authorization: request.authorization,
body: request.body,
});
self.responses.lock().unwrap().remove(0)
}
}
#[test]
fn version_gets_gitea_server_version() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 200,
body: json!({ "version": "1.22.4" }),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000/", transport.clone());
let version = api.version().unwrap();
assert_eq!(version.version, "1.22.4");
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, HttpMethod::Get);
assert_eq!(requests[0].url, "http://gitea.local:3000/api/v1/version");
assert_eq!(requests[0].authorization, None);
assert_eq!(requests[0].body, None);
}
#[test]
fn repo_maps_found_and_not_found_responses() {
let transport = FakeTransport::new(vec![
Ok(GiteaHttpResponse {
status: 200,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
}),
Ok(GiteaHttpResponse {
status: 404,
body: json!({ "message": "not found" }),
}),
]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let found = api.repo("joe", "notfiles-config").unwrap().unwrap();
let missing = api.repo("joe", "missing").unwrap();
assert_eq!(found.owner, "joe");
assert_eq!(found.name, "notfiles-config");
assert_eq!(
found.clone_url,
"http://gitea.local:3000/joe/notfiles-config.git"
);
assert_eq!(
found.ssh_url,
"ssh://git@gitea.local:2222/joe/notfiles-config.git"
);
assert_eq!(missing, None);
let requests = transport.requests();
assert_eq!(
requests[0].url,
"http://gitea.local:3000/api/v1/repos/joe/notfiles-config"
);
assert_eq!(
requests[1].url,
"http://gitea.local:3000/api/v1/repos/joe/missing"
);
}
#[test]
fn create_repo_posts_json_with_token_auth() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 201,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let spec = RepoSpec {
owner: "joe".to_string(),
name: "notfiles-config".to_string(),
private: true,
description: Some("Personal config payload".to_string()),
remote_name: "gitea".to_string(),
};
let repo = api
.create_repo(
&spec,
&ForgeAuth::Token {
token: "redacted".to_string(),
},
)
.unwrap();
assert_eq!(repo.owner, "joe");
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, HttpMethod::Post);
assert_eq!(requests[0].url, "http://gitea.local:3000/api/v1/user/repos");
assert_eq!(
requests[0].authorization,
Some("token redacted".to_string())
);
assert_eq!(
requests[0].body,
Some(json!({
"name": "notfiles-config",
"private": true,
"description": "Personal config payload"
}))
);
}
#[test]
fn create_repo_posts_json_with_basic_auth() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 201,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let spec = RepoSpec {
owner: "joe".to_string(),
name: "notfiles-config".to_string(),
private: true,
description: None,
remote_name: "gitea".to_string(),
};
api.create_repo(
&spec,
&ForgeAuth::Basic {
username: "joe".to_string(),
password: "secret".to_string(),
},
)
.unwrap();
let requests = transport.requests();
assert_eq!(
requests[0].authorization,
Some("Basic am9lOnNlY3JldA==".to_string())
);
assert_eq!(
requests[0].body,
Some(json!({
"name": "notfiles-config",
"private": true
}))
);
}

BIN
crates/notgraph/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -17,9 +17,9 @@ tempfile = { workspace = true }
[dependencies]
anyhow = { workspace = true }
cargo_metadata = "0.18"
clap = { workspace = true }
serde = { workspace = true }
serde_json = "1"
cargo_metadata = "0.18"
syn = { version = "2", features = ["full", "visit"] }
walkdir = "2"

View File

@@ -1,4 +1,5 @@
use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
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 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) {
if s.fan_in > 0 {
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();
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) {
if s.fan_out > 0 {
result.push(Hotspot {

BIN
crates/nothooks/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -13,11 +13,11 @@ name = "nothooks"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
notcore = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
clap = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

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 }
rpassword = { workspace = true }
serde = { workspace = true }
thiserror = { 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

@@ -5,24 +5,30 @@ edition = "2024"
license.workspace = true
[dependencies]
notcore = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
rpassword = { workspace = true }
dirs = { workspace = true }
x25519-dalek = { version = "2", features = ["static_secrets"] }
ed25519-dalek = "2"
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
hkdf = "0.12"
chacha20poly1305 = "0.10"
scrypt = "0.11"
bech32 = "0.11"
base64 = "0.22"
hmac = "0.12"
rand = "0.8"
zeroize = "1"
bech32 = "0.11"
chacha20poly1305 = "0.10"
curve25519-dalek = "4"
dirs = { workspace = true }
ed25519-dalek = "2"
hkdf = "0.12"
hmac = "0.12"
notcore = { workspace = true }
rand = "0.8"
rpassword = { workspace = true }
scrypt = "0.11"
serde = { workspace = true }
sha2 = "0.10"
thiserror = { workspace = true }
toml = { workspace = true }
x25519-dalek = { version = "2", features = ["static_secrets"] }
yubikey = { version = "0.8", optional = true }
zeroize = "1"
[features]
default = []
yubikey = ["dep:yubikey"]
[dev-dependencies]
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}")]
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 use scrypt::ScryptIdentity;
pub mod ssh_rsa;
pub use ssh_rsa::SshRsaIdentity;
pub mod encrypted;
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 encrypt;
pub mod error;
@@ -5,20 +6,25 @@ pub mod format;
pub mod identities;
pub mod ports;
pub mod recipients;
pub mod resolver;
pub mod sources;
pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config};
pub use decrypt::Decryptor;
pub use encrypt::Encryptor;
pub use error::AgeError;
pub use error::SecretsError;
pub use identities::{
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
SshRsaIdentity, Stanza, X25519Identity,
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
X25519Identity,
};
pub use ports::IdentitySource;
pub use recipients::{
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, 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.
///

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 std::collections::HashMap;
/// Infra boundary trait: an identity resolver that loads key material from an
/// external source and returns a concrete Identity.
@@ -10,3 +12,51 @@ pub trait IdentitySource {
/// Load and return a concrete identity from the source.
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 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.
pub trait Recipient {
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::x25519::X25519Identity;
use crate::ports::IdentitySource;
use crate::ports::{IdentitySource, SecretSource};
use std::io::Write;
use std::process::{Command, Stdio};
@@ -24,7 +25,7 @@ impl BitwardenSource {
.unwrap_or(false);
if !bw_ok {
return Err(AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw CLI not found in PATH"),
});
}
@@ -34,7 +35,7 @@ impl BitwardenSource {
let password =
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("could not read password: {e}"),
}
})?;
@@ -46,26 +47,26 @@ impl BitwardenSource {
.stderr(Stdio::piped())
.spawn()
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock spawn: {e}"),
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(password.as_bytes())
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
})?;
}
let output = child
.wait_with_output()
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock wait: {e}"),
})?;
if !output.status.success() {
return Err(AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!(
"bw unlock failed: {}",
String::from_utf8_lossy(&output.stderr)
@@ -74,7 +75,7 @@ impl BitwardenSource {
}
String::from_utf8(output.stdout)
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
})?
.trim()
@@ -90,12 +91,12 @@ impl BitwardenSource {
.args(["get", "notes", &self.item_name, "--session", &session])
.output()
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw get spawn: {e}"),
})?;
if !output.status.success() {
return Err(AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!(
"bw get notes '{}' failed: {}",
self.item_name,
@@ -105,14 +106,14 @@ impl BitwardenSource {
}
let key = String::from_utf8(output.stdout)
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
source: anyhow::anyhow!("bw output UTF-8: {e}"),
})?
.trim()
.to_string();
if key.is_empty() {
return Err(AgeError::SourceError {
name: self.name().to_string(),
name: "bitwarden".to_string(),
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 {
fn name(&self) -> &str {
"bitwarden"
@@ -129,7 +144,7 @@ impl IdentitySource for BitwardenSource {
let key = self.retrieve_key()?;
let identity =
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}"),
})?;
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 direnv;
pub mod dotenvx;
pub mod dotenvy;
pub mod env;
pub mod file;
pub mod gsm;
pub mod mise;
pub mod nuenv;
pub mod op;
pub mod prompt;
pub mod sops;
pub mod vault;
pub mod yubikey;
pub use crate::ports::IdentitySource;
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 gsm::GsmSource;
pub use mise::MiseSource;
pub use nuenv::NuenvSource;
pub use op::OpSource;
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

@@ -14,12 +14,13 @@ name = "notstrap"
path = "src/lib.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
notcore = { workspace = true }
notfiles = { workspace = true }
notsecrets = { workspace = true }
nothooks = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
which = { workspace = true }
notnet = { workspace = true }
notsecrets = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
which = { workspace = true }

View File

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

View File

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

BIN
docs/superpowers/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

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