Compare commits
18 Commits
refactor/f
...
chore/sort
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cfa0d9b3c | |||
| 1a3aaf2dd9 | |||
| 3a475bc25b | |||
| 039f8d808d | |||
| ca7c035ea5 | |||
| 25624c9f2b | |||
| 6427d188e6 | |||
| 12ad322608 | |||
| cb732c24ce | |||
| 268c981545 | |||
| 32cb5f7516 | |||
| 02ed15d6ee | |||
| 50ff598684 | |||
| 1359def673 | |||
|
|
65d1a2dc2e | ||
|
|
da89fab58a | ||
|
|
c7c074d48b | ||
|
|
dfdd9ca6d3 |
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal 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
25
.codex/hooks.json
Normal 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
16
.ctx/HANDOFF.md
Normal 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
|
||||
10
.ctx/HANDOFF.notfiles.notfiles.state.yaml
Normal file
10
.ctx/HANDOFF.notfiles.notfiles.state.yaml
Normal 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
|
||||
@@ -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: []
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -32,6 +32,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: hustcer/setup-nu@v3
|
||||
with:
|
||||
version: 0.112.2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,11 +1,15 @@
|
||||
/target
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.envrc
|
||||
.worktrees/
|
||||
.claude.local.md
|
||||
docs/
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
librust_out.rlib
|
||||
.remember/
|
||||
!docs/superpowers/
|
||||
|
||||
|
||||
# handoff-begin
|
||||
|
||||
76
.notfiles-state.toml
Normal file
76
.notfiles-state.toml
Normal 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
11
.sccignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.ctx
|
||||
.kgx
|
||||
.github
|
||||
.claude
|
||||
docs
|
||||
examples
|
||||
fuzz
|
||||
target
|
||||
*.md
|
||||
*.json
|
||||
tests/
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1776399095365}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
72766
|
||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal 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.
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -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 |
|
||||
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
||||
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
||||
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, heatmap, cycle detection |
|
||||
| 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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
782
Cargo.lock
generated
782
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -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,21 +12,29 @@ members = [
|
||||
"crates/notfiles",
|
||||
"crates/notsecrets",
|
||||
"crates/nothooks",
|
||||
"crates/notnet",
|
||||
"crates/notstrap",
|
||||
"crates/notgraph",
|
||||
"crates/notforge",
|
||||
"tests/integration",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
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"
|
||||
miette = { version = "7", features = ["derive"] }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
which = "7"
|
||||
rpassword = "7"
|
||||
tempfile = "3"
|
||||
@@ -30,6 +43,8 @@ notcore = { path = "crates/notcore" }
|
||||
notfiles = { path = "crates/notfiles" }
|
||||
notsecrets = { path = "crates/notsecrets" }
|
||||
nothooks = { path = "crates/nothooks" }
|
||||
notnet = { path = "crates/notnet" }
|
||||
notforge = { path = "crates/notforge" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
3
Justfile
3
Justfile
@@ -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
|
||||
|
||||
64
README.md
64
README.md
@@ -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
|
||||
│
|
||||
├─ conflict_check() existing file? symlink to wrong target?
|
||||
├─ collect_files() recursive walk, apply ignore patterns
|
||||
│
|
||||
└─ linker::link_package() create symlinks (or copies), write state
|
||||
├─ conflict_check() existing file? symlink to wrong target?
|
||||
│
|
||||
└─ 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"]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -18,6 +19,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 +34,8 @@ impl Default for Defaults {
|
||||
Self {
|
||||
target: default_target(),
|
||||
ignore: default_ignore(),
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +63,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)]
|
||||
@@ -71,6 +86,22 @@ impl std::fmt::Display for Method {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and deserialize a TOML config file, mapping I/O and parse errors
|
||||
/// into the caller's crate-specific error type.
|
||||
pub fn load_toml_file<T, E, ReadError, ParseError>(
|
||||
path: &Path,
|
||||
read_error: ReadError,
|
||||
parse_error: ParseError,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
ReadError: FnOnce(&Path, std::io::Error) -> E,
|
||||
ParseError: FnOnce(&Path, toml::de::Error) -> E,
|
||||
{
|
||||
let content = std::fs::read_to_string(path).map_err(|err| read_error(path, err))?;
|
||||
toml::from_str(&content).map_err(|err| parse_error(path, err))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
||||
let config_path = dotfiles_dir.join("notfiles.toml");
|
||||
@@ -100,6 +131,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 +186,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 = "~"
|
||||
@@ -129,6 +221,10 @@ ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfil
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_config_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("notcore-{name}-{}.toml", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
@@ -138,6 +234,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#"
|
||||
@@ -162,4 +344,40 @@ target = "~/bin"
|
||||
assert!(ssh_ignores.contains(&".git"));
|
||||
assert!(ssh_ignores.contains(&"known_hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_toml_file_deserializes_config() {
|
||||
let path = temp_config_path("load-toml-file-ok");
|
||||
std::fs::write(&path, "[defaults]\ntarget = \"~/dotfiles\"\n")
|
||||
.expect("test should write temporary config");
|
||||
|
||||
let config: Config = load_toml_file(
|
||||
&path,
|
||||
|path, err| format!("read {}: {err}", path.display()),
|
||||
|path, err| format!("parse {}: {err}", path.display()),
|
||||
)
|
||||
.expect("temporary config should parse");
|
||||
|
||||
assert_eq!(config.defaults.target, "~/dotfiles");
|
||||
|
||||
std::fs::remove_file(&path).expect("test should remove temporary config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_toml_file_maps_parse_error() {
|
||||
let path = temp_config_path("load-toml-file-parse-error");
|
||||
std::fs::write(&path, "[defaults\ntarget = \"~/dotfiles\"\n")
|
||||
.expect("test should write invalid temporary config");
|
||||
|
||||
let result: Result<Config, String> = load_toml_file(
|
||||
&path,
|
||||
|path, err| format!("read {}: {err}", path.display()),
|
||||
|path, err| format!("parse {}: {err}", path.display()),
|
||||
);
|
||||
|
||||
let err = result.expect_err("invalid TOML should return parse error");
|
||||
assert!(err.contains("parse "));
|
||||
|
||||
std::fs::remove_file(&path).expect("test should remove temporary config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
46
crates/notcore/src/reporter.rs
Normal file
46
crates/notcore/src/reporter.rs
Normal 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<'_>) {}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
293
crates/notfiles/src/adapters/memory.rs
Normal file
293
crates/notfiles/src/adapters/memory.rs
Normal 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"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
81
crates/notfiles/src/adapters/reporter.rs
Normal file
81
crates/notfiles/src/adapters/reporter.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
300
crates/notfiles/src/detect.rs
Normal file
300
crates/notfiles/src/detect.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,19 +124,22 @@ 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> {
|
||||
if !opts.dry_run {
|
||||
let _ = state.save(dotfiles_dir, fs);
|
||||
}
|
||||
Err(e)
|
||||
};
|
||||
let save_and_return =
|
||||
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
|
||||
if !opts.dry_run {
|
||||
let _ = state.save(dotfiles_dir, fs);
|
||||
}
|
||||
Err(e)
|
||||
};
|
||||
|
||||
// Conflict detection
|
||||
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,15 +135,100 @@ 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);
|
||||
status::print_status(pkg, &entries);
|
||||
if cli.json {
|
||||
status::print_status_json(pkg, &entries);
|
||||
} else {
|
||||
status::print_status(pkg, &entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal file
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal 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
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[font]
|
||||
size = 14.0
|
||||
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Bootstrap Runbook
|
||||
|
||||
Steps for setting up a new machine.
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Example fish config
|
||||
set -gx EDITOR nvim
|
||||
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
[user]
|
||||
name = Example User
|
||||
email = user@example.com
|
||||
[init]
|
||||
defaultBranch = main
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
node = "lts"
|
||||
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = with pkgs; [ vim git ];
|
||||
}
|
||||
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal file
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal 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"]
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Example aliases
|
||||
alias ll = ls -l
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Example nushell config
|
||||
$env.config.show_banner = false
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Example nushell env
|
||||
$env.EDITOR = "nvim"
|
||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Health check script — NOT a stow package.
|
||||
echo "All good"
|
||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers — NOT a stow package.
|
||||
log() { echo "[$(date)] $*"; }
|
||||
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"EXAMPLE_KEY": "replace-me"
|
||||
}
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[character]
|
||||
success_symbol = "[>](bold green)"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.fontSize": 14,
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Example zshrc
|
||||
export EDITOR=nvim
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
33
crates/notforge/Cargo.toml
Normal file
33
crates/notforge/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "notforge"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
description = "Forge lifecycle and repository provisioning for notfiles"
|
||||
|
||||
[lib]
|
||||
name = "notforge"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "notforge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
notcore = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
176
crates/notforge/src/config.rs
Normal file
176
crates/notforge/src/config.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::NotforgeError;
|
||||
|
||||
/// Top-level forge configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForgeConfig {
|
||||
/// Gitea backend configuration.
|
||||
pub gitea: GiteaConfig,
|
||||
/// Repositories managed by this forge configuration.
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<RepoSpec>,
|
||||
}
|
||||
|
||||
/// Gitea backend configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GiteaConfig {
|
||||
/// Base HTTP URL for the Gitea API.
|
||||
pub base_url: String,
|
||||
/// Default owner namespace for repositories.
|
||||
pub owner: String,
|
||||
/// Hostname used in generated SSH clone URLs.
|
||||
pub ssh_host: String,
|
||||
/// SSH port used in generated SSH clone URLs.
|
||||
pub ssh_port: u16,
|
||||
/// Backend mode used to verify or start Gitea.
|
||||
pub mode: GiteaMode,
|
||||
/// Authentication configuration for Gitea API calls.
|
||||
#[serde(default)]
|
||||
pub auth: ForgeAuthConfig,
|
||||
}
|
||||
|
||||
/// Supported Gitea backend modes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum GiteaMode {
|
||||
/// Run Gitea directly as a local process.
|
||||
LocalProcess(LocalProcessConfig),
|
||||
/// Run Gitea via a local container runtime.
|
||||
LocalContainer(LocalContainerConfig),
|
||||
/// Verify or start Gitea as a systemd service on a VM.
|
||||
VmSystemd(VmSystemdConfig),
|
||||
/// Use an already-running Gitea instance.
|
||||
Existing,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GiteaMode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawMode {
|
||||
Name(String),
|
||||
Tagged {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
config: Option<toml::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
let raw = RawMode::deserialize(deserializer)?;
|
||||
match raw {
|
||||
RawMode::Name(name) if name == "existing" => Ok(Self::Existing),
|
||||
RawMode::Name(name) => Err(serde::de::Error::custom(format!(
|
||||
"unknown gitea mode '{name}'"
|
||||
))),
|
||||
RawMode::Tagged { kind, config } => match kind.as_str() {
|
||||
"existing" => Ok(Self::Existing),
|
||||
"local-process" => Ok(Self::LocalProcess(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("local-process requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
"local-container" => Ok(Self::LocalContainer(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("local-container requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
"vm-systemd" => Ok(Self::VmSystemd(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("vm-systemd requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"unknown gitea mode '{other}'"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local process runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalProcessConfig {
|
||||
/// Path or command name for the Gitea binary.
|
||||
pub binary: String,
|
||||
/// Working directory for the Gitea process.
|
||||
pub work_dir: String,
|
||||
/// Gitea app.ini path.
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
/// Local container runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalContainerConfig {
|
||||
/// Container runtime executable, such as docker or podman.
|
||||
pub runtime: String,
|
||||
/// Container name to verify or start.
|
||||
pub container_name: String,
|
||||
/// Container image to run when missing.
|
||||
pub image: String,
|
||||
/// Host data directory mounted into the container.
|
||||
pub data_dir: String,
|
||||
}
|
||||
|
||||
/// Remote VM systemd runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VmSystemdConfig {
|
||||
/// SSH user for the VM.
|
||||
pub ssh_user: String,
|
||||
/// SSH host for the VM.
|
||||
pub ssh_host: String,
|
||||
/// systemd service name for Gitea.
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
/// Authentication configuration for Gitea API calls.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ForgeAuthConfig {
|
||||
/// Token secret reference, preferred when available.
|
||||
pub token: Option<ForgeSecretRef>,
|
||||
/// Username secret reference for basic auth fallback.
|
||||
pub username: Option<ForgeSecretRef>,
|
||||
/// Password secret reference for basic auth fallback.
|
||||
pub password: Option<ForgeSecretRef>,
|
||||
}
|
||||
|
||||
/// Secret reference used by notforge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
pub enum ForgeSecretRef {
|
||||
/// Resolve from an environment variable.
|
||||
Env { key: String },
|
||||
/// Resolve from a 1Password URI.
|
||||
Op { uri: String },
|
||||
}
|
||||
|
||||
/// Repository specification managed by a forge config.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepoSpec {
|
||||
/// Repository owner namespace.
|
||||
pub owner: String,
|
||||
/// Repository name.
|
||||
pub name: String,
|
||||
/// Whether the repository should be private.
|
||||
pub private: bool,
|
||||
/// Optional repository description.
|
||||
pub description: Option<String>,
|
||||
/// Local git remote name.
|
||||
pub remote_name: String,
|
||||
}
|
||||
|
||||
/// Load a forge configuration from TOML.
|
||||
pub fn load_config(path: &Path) -> Result<ForgeConfig, NotforgeError> {
|
||||
notcore::config::load_toml_file(
|
||||
path,
|
||||
|path, err| NotforgeError::Config(format!("reading {}: {err}", path.display())),
|
||||
|path, err| NotforgeError::Config(format!("parsing {}: {err}", path.display())),
|
||||
)
|
||||
}
|
||||
36
crates/notforge/src/error.rs
Normal file
36
crates/notforge/src/error.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error type for forge lifecycle, API, git, and secret-resolution failures.
|
||||
#[derive(Debug, Diagnostic, Error)]
|
||||
pub enum NotforgeError {
|
||||
/// Configuration could not be loaded or parsed.
|
||||
#[error("configuration error: {0}")]
|
||||
#[diagnostic(code(notforge::config))]
|
||||
Config(String),
|
||||
|
||||
/// A required secret could not be resolved.
|
||||
#[error("secret resolution error: {0}")]
|
||||
#[diagnostic(code(notforge::secret))]
|
||||
Secret(String),
|
||||
|
||||
/// Forge API communication failed.
|
||||
#[error("HTTP error: {0}")]
|
||||
#[diagnostic(code(notforge::http))]
|
||||
Http(String),
|
||||
|
||||
/// A lifecycle command failed.
|
||||
#[error("command error: {0}")]
|
||||
#[diagnostic(code(notforge::command))]
|
||||
Command(String),
|
||||
|
||||
/// A git operation failed.
|
||||
#[error("git error: {0}")]
|
||||
#[diagnostic(code(notforge::git))]
|
||||
Git(String),
|
||||
|
||||
/// Repository lookup or provisioning failed.
|
||||
#[error("repository error: {0}")]
|
||||
#[diagnostic(code(notforge::repository))]
|
||||
Repository(String),
|
||||
}
|
||||
302
crates/notforge/src/gitea.rs
Normal file
302
crates/notforge/src/gitea.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::RepoSpec;
|
||||
use crate::error::NotforgeError;
|
||||
use crate::ports::{ForgeApi, ForgeAuth, ForgeVersion, RemoteRepository};
|
||||
|
||||
/// HTTP method used by the Gitea transport boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpMethod {
|
||||
/// HTTP GET.
|
||||
Get,
|
||||
/// HTTP POST.
|
||||
Post,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpMethod {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Get => formatter.write_str("GET"),
|
||||
Self::Post => formatter.write_str("POST"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level request sent by the Gitea adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GiteaHttpRequest {
|
||||
/// HTTP method.
|
||||
pub method: HttpMethod,
|
||||
/// Absolute URL to call.
|
||||
pub url: String,
|
||||
/// Optional Authorization header value.
|
||||
pub authorization: Option<String>,
|
||||
/// Optional JSON body.
|
||||
pub body: Option<Value>,
|
||||
}
|
||||
|
||||
/// Transport-level response returned to the Gitea adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GiteaHttpResponse {
|
||||
/// HTTP status code.
|
||||
pub status: u16,
|
||||
/// Parsed JSON response body, or `null` for an empty body.
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
/// HTTP transport boundary for Gitea API requests.
|
||||
pub trait GiteaTransport {
|
||||
/// Send a transport-level request.
|
||||
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Gitea API adapter that implements [`ForgeApi`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GiteaHttpApi<T = UreqGiteaTransport> {
|
||||
base_url: String,
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl GiteaHttpApi<UreqGiteaTransport> {
|
||||
/// Create a Gitea API adapter backed by the default blocking HTTP transport.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_transport(base_url, UreqGiteaTransport::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GiteaHttpApi<T>
|
||||
where
|
||||
T: GiteaTransport,
|
||||
{
|
||||
/// Create a Gitea API adapter with an injected transport.
|
||||
pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base_url, path)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
self.transport.send(GiteaHttpRequest {
|
||||
method: HttpMethod::Get,
|
||||
url: self.url(path),
|
||||
authorization: None,
|
||||
body: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn post(
|
||||
&self,
|
||||
path: &str,
|
||||
auth: &ForgeAuth,
|
||||
body: Value,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
self.transport.send(GiteaHttpRequest {
|
||||
method: HttpMethod::Post,
|
||||
url: self.url(path),
|
||||
authorization: Some(authorization_header(auth)),
|
||||
body: Some(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ForgeApi for GiteaHttpApi<T>
|
||||
where
|
||||
T: GiteaTransport,
|
||||
{
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
|
||||
let response = self.get("/api/v1/version")?;
|
||||
require_status(response.status, &[200], "get Gitea version")?;
|
||||
let payload: GiteaVersionResponse = parse_body(response.body, "Gitea version response")?;
|
||||
Ok(ForgeVersion {
|
||||
version: payload.version,
|
||||
})
|
||||
}
|
||||
|
||||
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError> {
|
||||
let response = self.get(&format!("/api/v1/repos/{owner}/{name}"))?;
|
||||
if response.status == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
require_status(response.status, &[200], "get Gitea repository")?;
|
||||
let payload: GiteaRepositoryResponse =
|
||||
parse_body(response.body, "Gitea repository response")?;
|
||||
Ok(Some(payload.into_remote_repository()))
|
||||
}
|
||||
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError> {
|
||||
let response = self.post("/api/v1/user/repos", auth, create_repo_body(spec))?;
|
||||
require_status(response.status, &[200, 201], "create Gitea repository")?;
|
||||
let payload: GiteaRepositoryResponse =
|
||||
parse_body(response.body, "Gitea repository response")?;
|
||||
Ok(payload.into_remote_repository())
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking HTTP transport implemented with `ureq`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UreqGiteaTransport {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl Default for UreqGiteaTransport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UreqGiteaTransport {
|
||||
/// Create a transport with a custom `ureq` agent.
|
||||
pub fn new(agent: ureq::Agent) -> Self {
|
||||
Self { agent }
|
||||
}
|
||||
}
|
||||
|
||||
impl GiteaTransport for UreqGiteaTransport {
|
||||
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
let method = request.method;
|
||||
let url = request.url.clone();
|
||||
let mut call = match method {
|
||||
HttpMethod::Get => self.agent.get(&url),
|
||||
HttpMethod::Post => self.agent.post(&url),
|
||||
}
|
||||
.set("Accept", "application/json");
|
||||
|
||||
if let Some(authorization) = request.authorization {
|
||||
call = call.set("Authorization", &authorization);
|
||||
}
|
||||
|
||||
let result = match method {
|
||||
HttpMethod::Get => call.call(),
|
||||
HttpMethod::Post => call
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(request.body.unwrap_or_else(|| json!({}))),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(response) => response_from_ureq(response),
|
||||
Err(ureq::Error::Status(status, response)) => response_from_status(status, response),
|
||||
Err(err) => Err(NotforgeError::Http(format!(
|
||||
"{method} {url} failed before response: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaVersionResponse {
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRepositoryResponse {
|
||||
owner: GiteaOwnerResponse,
|
||||
name: String,
|
||||
clone_url: String,
|
||||
ssh_url: String,
|
||||
}
|
||||
|
||||
impl GiteaRepositoryResponse {
|
||||
fn into_remote_repository(self) -> RemoteRepository {
|
||||
RemoteRepository {
|
||||
owner: self.owner.login,
|
||||
name: self.name,
|
||||
clone_url: self.clone_url,
|
||||
ssh_url: self.ssh_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaOwnerResponse {
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateRepoRequest<'a> {
|
||||
name: &'a str,
|
||||
private: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn create_repo_body(spec: &RepoSpec) -> Value {
|
||||
serde_json::to_value(CreateRepoRequest {
|
||||
name: &spec.name,
|
||||
private: spec.private,
|
||||
description: spec.description.as_deref(),
|
||||
})
|
||||
.expect("CreateRepoRequest serialization should not fail")
|
||||
}
|
||||
|
||||
fn authorization_header(auth: &ForgeAuth) -> String {
|
||||
match auth {
|
||||
ForgeAuth::Token { token } => format!("token {token}"),
|
||||
ForgeAuth::Basic { username, password } => {
|
||||
format!(
|
||||
"Basic {}",
|
||||
STANDARD.encode(format!("{username}:{password}"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_status(status: u16, expected: &[u16], action: &str) -> Result<(), NotforgeError> {
|
||||
if expected.contains(&status) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NotforgeError::Http(format!(
|
||||
"{action} returned unexpected status {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_body<T>(body: Value, context: &str) -> Result<T, NotforgeError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
serde_json::from_value(body)
|
||||
.map_err(|err| NotforgeError::Http(format!("invalid {context}: {err}")))
|
||||
}
|
||||
|
||||
fn response_from_status(
|
||||
status: u16,
|
||||
response: ureq::Response,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
response_from_parts(status, response)
|
||||
}
|
||||
|
||||
fn response_from_ureq(response: ureq::Response) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
response_from_parts(response.status(), response)
|
||||
}
|
||||
|
||||
fn response_from_parts(
|
||||
status: u16,
|
||||
response: ureq::Response,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
let body = response
|
||||
.into_string()
|
||||
.map_err(|err| NotforgeError::Http(format!("reading HTTP response body: {err}")))?;
|
||||
let body = if body.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|err| NotforgeError::Http(format!("parsing HTTP response JSON: {err}")))?
|
||||
};
|
||||
|
||||
Ok(GiteaHttpResponse { status, body })
|
||||
}
|
||||
19
crates/notforge/src/lib.rs
Normal file
19
crates/notforge/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Forge lifecycle and repository provisioning for notfiles.
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod gitea;
|
||||
pub mod ports;
|
||||
|
||||
/// Current `notforge` crate version.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exposes_package_version() {
|
||||
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
}
|
||||
3
crates/notforge/src/main.rs
Normal file
3
crates/notforge/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("{}", notforge::VERSION);
|
||||
}
|
||||
126
crates/notforge/src/ports.rs
Normal file
126
crates/notforge/src/ports.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{ForgeConfig, ForgeSecretRef, RepoSpec};
|
||||
use crate::error::NotforgeError;
|
||||
|
||||
/// Authentication material resolved for forge API requests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ForgeAuth {
|
||||
/// Token-based Gitea API authentication.
|
||||
Token { token: String },
|
||||
/// Basic authentication fallback for bootstrap flows.
|
||||
Basic { username: String, password: String },
|
||||
}
|
||||
|
||||
/// Remote repository metadata returned by a forge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteRepository {
|
||||
/// Repository owner namespace.
|
||||
pub owner: String,
|
||||
/// Repository name.
|
||||
pub name: String,
|
||||
/// HTTP clone URL.
|
||||
pub clone_url: String,
|
||||
/// SSH clone URL.
|
||||
pub ssh_url: String,
|
||||
}
|
||||
|
||||
/// Forge version metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ForgeVersion {
|
||||
/// Server version string.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Local git repository path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalRepository {
|
||||
/// Filesystem path to the local repository.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Result of ensuring a forge backend is available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LifecycleStatus {
|
||||
/// The forge was already reachable.
|
||||
AlreadyAvailable,
|
||||
/// The forge was started by this operation.
|
||||
Started,
|
||||
/// The forge was verified without needing a start operation.
|
||||
Verified,
|
||||
}
|
||||
|
||||
/// Result of setting a local git remote.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteStatus {
|
||||
/// The remote was added.
|
||||
Added,
|
||||
/// The remote existed and was updated.
|
||||
Updated,
|
||||
/// The remote already matched the desired URL.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Result of pushing a local branch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PushStatus {
|
||||
/// The branch was pushed.
|
||||
Pushed,
|
||||
/// The branch already matched the remote.
|
||||
AlreadyUpToDate,
|
||||
}
|
||||
|
||||
/// Port for forge API operations.
|
||||
pub trait ForgeApi {
|
||||
/// Return server version metadata.
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError>;
|
||||
|
||||
/// Look up a repository by owner and name.
|
||||
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError>;
|
||||
|
||||
/// Create a repository from a spec and resolved auth.
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for local or remote forge lifecycle operations.
|
||||
pub trait ForgeLifecycle {
|
||||
/// Ensure the configured forge backend is available.
|
||||
fn ensure_available(&self, config: &ForgeConfig) -> Result<LifecycleStatus, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for git remote and push operations.
|
||||
pub trait GitRemoteManager {
|
||||
/// Return the URL for a local remote if it exists.
|
||||
fn remote_url(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
) -> Result<Option<String>, NotforgeError>;
|
||||
|
||||
/// Add or update a local git remote.
|
||||
fn set_remote(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
url: &str,
|
||||
) -> Result<RemoteStatus, NotforgeError>;
|
||||
|
||||
/// Push a branch to a local remote.
|
||||
fn push(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
branch: &str,
|
||||
set_upstream: bool,
|
||||
) -> Result<PushStatus, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for resolving forge secrets.
|
||||
pub trait SecretResolverPort {
|
||||
/// Resolve a secret reference into an in-memory secret value.
|
||||
fn resolve(&self, secret: &ForgeSecretRef) -> Result<String, NotforgeError>;
|
||||
}
|
||||
222
crates/notforge/tests/api.rs
Normal file
222
crates/notforge/tests/api.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use notforge::config::{
|
||||
ForgeAuthConfig, ForgeConfig, ForgeSecretRef, GiteaMode, RepoSpec, VmSystemdConfig, load_config,
|
||||
};
|
||||
use notforge::error::NotforgeError;
|
||||
use notforge::ports::{
|
||||
ForgeApi, ForgeAuth, ForgeLifecycle, ForgeVersion, GitRemoteManager, LifecycleStatus,
|
||||
LocalRepository, PushStatus, RemoteRepository, RemoteStatus, SecretResolverPort,
|
||||
};
|
||||
|
||||
fn assert_diagnostic<E: miette::Diagnostic>() {}
|
||||
|
||||
#[test]
|
||||
fn notforge_error_implements_miette_diagnostic() {
|
||||
assert_diagnostic::<NotforgeError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_parses_vm_systemd_gitea_mode() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config_path = dir.path().join("notforge.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
[gitea]
|
||||
base_url = "http://gitea.local:3000"
|
||||
owner = "joe"
|
||||
ssh_host = "gitea.local"
|
||||
ssh_port = 2222
|
||||
|
||||
[gitea.mode]
|
||||
type = "vm-systemd"
|
||||
|
||||
[gitea.mode.config]
|
||||
ssh_user = "dev"
|
||||
ssh_host = "gitea.local"
|
||||
service_name = "gitea"
|
||||
|
||||
[gitea.auth.token]
|
||||
source = "env"
|
||||
key = "GITEA_TOKEN"
|
||||
|
||||
[[repositories]]
|
||||
owner = "joe"
|
||||
name = "notfiles-config"
|
||||
private = true
|
||||
description = "Personal config payload"
|
||||
remote_name = "gitea"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(&config_path).unwrap();
|
||||
|
||||
assert_eq!(config.gitea.base_url, "http://gitea.local:3000");
|
||||
assert_eq!(config.gitea.owner, "joe");
|
||||
assert_eq!(config.gitea.ssh_port, 2222);
|
||||
assert_eq!(
|
||||
config.gitea.auth.token,
|
||||
Some(ForgeSecretRef::Env {
|
||||
key: "GITEA_TOKEN".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
config.gitea.mode,
|
||||
GiteaMode::VmSystemd(VmSystemdConfig {
|
||||
ssh_user: "dev".to_string(),
|
||||
ssh_host: "gitea.local".to_string(),
|
||||
service_name: "gitea".to_string(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
config.repositories,
|
||||
vec![RepoSpec {
|
||||
owner: "joe".to_string(),
|
||||
name: "notfiles-config".to_string(),
|
||||
private: true,
|
||||
description: Some("Personal config payload".to_string()),
|
||||
remote_name: "gitea".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_repositories_to_empty() {
|
||||
let config: ForgeConfig = toml::from_str(
|
||||
r#"
|
||||
[gitea]
|
||||
base_url = "http://localhost:3000"
|
||||
owner = "joe"
|
||||
ssh_host = "localhost"
|
||||
ssh_port = 2222
|
||||
mode = "existing"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.repositories.is_empty());
|
||||
assert_eq!(config.gitea.auth, ForgeAuthConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_are_object_safe_for_adapters() {
|
||||
struct FakeApi;
|
||||
impl ForgeApi for FakeApi {
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
|
||||
Ok(ForgeVersion {
|
||||
version: "1.0.0".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn repo(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_name: &str,
|
||||
) -> Result<Option<RemoteRepository>, NotforgeError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
_auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError> {
|
||||
Ok(RemoteRepository {
|
||||
owner: spec.owner.clone(),
|
||||
name: spec.name.clone(),
|
||||
clone_url: "http://example/repo.git".to_string(),
|
||||
ssh_url: "ssh://git@example/joe/repo.git".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeLifecycle;
|
||||
impl ForgeLifecycle for FakeLifecycle {
|
||||
fn ensure_available(
|
||||
&self,
|
||||
_config: &ForgeConfig,
|
||||
) -> Result<LifecycleStatus, NotforgeError> {
|
||||
Ok(LifecycleStatus::AlreadyAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeGit;
|
||||
impl GitRemoteManager for FakeGit {
|
||||
fn remote_url(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
) -> Result<Option<String>, NotforgeError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_remote(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
_url: &str,
|
||||
) -> Result<RemoteStatus, NotforgeError> {
|
||||
Ok(RemoteStatus::Added)
|
||||
}
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
_branch: &str,
|
||||
_set_upstream: bool,
|
||||
) -> Result<PushStatus, NotforgeError> {
|
||||
Ok(PushStatus::Pushed)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSecrets;
|
||||
impl SecretResolverPort for FakeSecrets {
|
||||
fn resolve(&self, _secret: &ForgeSecretRef) -> Result<String, NotforgeError> {
|
||||
Ok("secret".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let api: &dyn ForgeApi = &FakeApi;
|
||||
let lifecycle: &dyn ForgeLifecycle = &FakeLifecycle;
|
||||
let git: &dyn GitRemoteManager = &FakeGit;
|
||||
let secrets: &dyn SecretResolverPort = &FakeSecrets;
|
||||
|
||||
assert_eq!(api.version().unwrap().version, "1.0.0");
|
||||
assert_eq!(
|
||||
lifecycle
|
||||
.ensure_available(&ForgeConfig {
|
||||
gitea: notforge::config::GiteaConfig {
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
owner: "joe".to_string(),
|
||||
ssh_host: "localhost".to_string(),
|
||||
ssh_port: 2222,
|
||||
mode: GiteaMode::Existing,
|
||||
auth: ForgeAuthConfig::default(),
|
||||
},
|
||||
repositories: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
LifecycleStatus::AlreadyAvailable
|
||||
);
|
||||
assert_eq!(
|
||||
git.remote_url(
|
||||
&LocalRepository {
|
||||
path: PathBuf::from(".")
|
||||
},
|
||||
"gitea"
|
||||
)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
secrets
|
||||
.resolve(&ForgeSecretRef::Env {
|
||||
key: "GITEA_TOKEN".to_string()
|
||||
})
|
||||
.unwrap(),
|
||||
"secret"
|
||||
);
|
||||
}
|
||||
203
crates/notforge/tests/gitea.rs
Normal file
203
crates/notforge/tests/gitea.rs
Normal 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
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
17
crates/notnet/Cargo.toml
Normal 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
49
crates/notnet/src/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/notnet/src/error.rs
Normal file
24
crates/notnet/src/error.rs
Normal 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),
|
||||
}
|
||||
92
crates/notnet/src/installer.rs
Normal file
92
crates/notnet/src/installer.rs
Normal 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
80
crates/notnet/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
13
crates/notnet/src/ports.rs
Normal file
13
crates/notnet/src/ports.rs
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
172
crates/notsecrets/src/config.rs
Normal file
172
crates/notsecrets/src/config.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
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> {
|
||||
notcore::config::load_toml_file(
|
||||
path,
|
||||
|path, err| SecretsError::Config(format!("cannot read {}: {err}", path.display())),
|
||||
|_path, err| SecretsError::Config(format!("parse error: {err}")),
|
||||
)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
222
crates/notsecrets/src/resolver.rs
Normal file
222
crates/notsecrets/src/resolver.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
35
crates/notsecrets/src/sources/direnv.rs
Normal file
35
crates/notsecrets/src/sources/direnv.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
crates/notsecrets/src/sources/dotenvx.rs
Normal file
89
crates/notsecrets/src/sources/dotenvx.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/dotenvy.rs
Normal file
35
crates/notsecrets/src/sources/dotenvy.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
62
crates/notsecrets/src/sources/env.rs
Normal file
62
crates/notsecrets/src/sources/env.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
92
crates/notsecrets/src/sources/gsm.rs
Normal file
92
crates/notsecrets/src/sources/gsm.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/mise.rs
Normal file
35
crates/notsecrets/src/sources/mise.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
37
crates/notsecrets/src/sources/nuenv.rs
Normal file
37
crates/notsecrets/src/sources/nuenv.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
84
crates/notsecrets/src/sources/op.rs
Normal file
84
crates/notsecrets/src/sources/op.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
crates/notsecrets/src/sources/sops.rs
Normal file
83
crates/notsecrets/src/sources/sops.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/vault.rs
Normal file
35
crates/notsecrets/src/sources/vault.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
crates/notsecrets/src/sources/yubikey.rs
Normal file
72
crates/notsecrets/src/sources/yubikey.rs
Normal 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"
|
||||
),
|
||||
})
|
||||
}
|
||||
82
crates/notsecrets/tests/conformance_secret_source.rs
Normal file
82
crates/notsecrets/tests/conformance_secret_source.rs
Normal 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"));
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
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).
|
||||
unsafe {
|
||||
std::env::set_var(&k, &v);
|
||||
// 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. 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("secrets", StepStatus::Failed(e.to_string()));
|
||||
return Ok(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
report.add("decrypt secrets", StepStatus::Failed(e.to_string()));
|
||||
return Ok(report);
|
||||
}
|
||||
report.add("secrets", StepStatus::Ok);
|
||||
}
|
||||
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);
|
||||
}
|
||||
report.add("decrypt 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
1445
docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md
Normal file
1445
docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md
Normal file
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
Reference in New Issue
Block a user