Compare commits
23 Commits
refactor/f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7df3d65c | ||
| d34a4f9363 | |||
| d9f476ff79 | |||
|
|
4b4b1df5db | ||
| 0cfa0d9b3c | |||
| 1a3aaf2dd9 | |||
| 3a475bc25b | |||
| 039f8d808d | |||
| ca7c035ea5 | |||
| 25624c9f2b | |||
| 6427d188e6 | |||
| 12ad322608 | |||
| cb732c24ce | |||
| 268c981545 | |||
| 32cb5f7516 | |||
| 02ed15d6ee | |||
| 50ff598684 | |||
| 1359def673 | |||
|
|
65d1a2dc2e | ||
|
|
da89fab58a | ||
|
|
c7c074d48b | ||
|
|
dfdd9ca6d3 | ||
|
|
4b344621bc |
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: $?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule .claude/worktrees/agent-a27fe5f2 deleted from 0d60583c65
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
.ctx/HANDOFF.md
Normal file
17
.ctx/HANDOFF.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Handoff — notfiles (2026-07-14)
|
||||||
|
|
||||||
|
**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-07-08: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo
|
||||||
|
- 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,37 @@
|
|||||||
project: notfiles
|
project: notfiles
|
||||||
id: notfile
|
id: notfile
|
||||||
updated: 2026-04-15
|
updated: 2026-07-14
|
||||||
branch: refactor/formalize-ports
|
items:
|
||||||
state:
|
- id: uncommitted-work
|
||||||
tests: passing
|
doob_uuid: null
|
||||||
build: clean
|
name: uncommitted-work
|
||||||
notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob.
|
priority: P1
|
||||||
items: []
|
status: open
|
||||||
|
title: Uncommitted changes (3 files)
|
||||||
|
description: |-
|
||||||
|
Working tree has uncommitted changes:
|
||||||
|
.claude/worktrees/agent-a27fe5f2
|
||||||
|
.claude/settings.local.json
|
||||||
|
.superpowers/brainstorm/72758-1776397235/state/server-stopped
|
||||||
|
files:
|
||||||
|
- .claude/worktrees/agent-a27fe5f2
|
||||||
|
- .claude/settings.local.json
|
||||||
|
- .superpowers/brainstorm/72758-1776397235/state/server-stopped
|
||||||
|
completed: null
|
||||||
|
extra: []
|
||||||
log:
|
log:
|
||||||
|
- date: '20260606.135918'
|
||||||
|
summary: done=13 running=0 pending=0 blocked=0
|
||||||
|
commits: []
|
||||||
|
- date: 2026-07-08
|
||||||
|
summary: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo
|
||||||
|
commits: []
|
||||||
|
- date: 2026-04-17
|
||||||
|
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
|
||||||
|
commits: []
|
||||||
- date: 2026-04-15
|
- date: 2026-04-15
|
||||||
summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
|
summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
|
||||||
commits:
|
commits: []
|
||||||
- 67f20ee
|
|
||||||
- date: 2026-04-15
|
- date: 2026-04-15
|
||||||
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
||||||
|
commits: []
|
||||||
|
|||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -32,6 +32,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: hustcer/setup-nu@v3
|
||||||
|
with:
|
||||||
|
version: 0.112.2
|
||||||
- uses: taiki-e/install-action@v2
|
- uses: taiki-e/install-action@v2
|
||||||
with:
|
with:
|
||||||
tool: cargo-nextest
|
tool: cargo-nextest
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,11 +1,16 @@
|
|||||||
/target
|
/target
|
||||||
|
.DS_Store
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
.envrc
|
.envrc
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.claude/worktrees/
|
||||||
.claude.local.md
|
.claude.local.md
|
||||||
docs/
|
docs/*
|
||||||
|
!docs/superpowers/
|
||||||
librust_out.rlib
|
librust_out.rlib
|
||||||
.remember/
|
.remember/
|
||||||
!docs/superpowers/
|
|
||||||
|
|
||||||
|
|
||||||
# handoff-begin
|
# handoff-begin
|
||||||
|
|||||||
76
.notfiles-state.toml
Normal file
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/
|
||||||
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.
|
||||||
75
CLAUDE.md
75
CLAUDE.md
@@ -1,5 +1,11 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
<preflight>
|
||||||
|
Read:
|
||||||
|
- @docs/DESIGN.md
|
||||||
|
- @.ctx/HANDOFF.md
|
||||||
|
</preflight>
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## What is notfiles?
|
## What is notfiles?
|
||||||
@@ -22,36 +28,64 @@ cargo fmt --check # check formatting
|
|||||||
|
|
||||||
## Workspace Structure
|
## Workspace Structure
|
||||||
|
|
||||||
This is a Cargo workspace with 5 crates under `crates/`:
|
This is a Cargo workspace with 7 crates under `crates/`:
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
| ------------ | -------------------------------------------------------------------------------------------------------- |
|
| ------------ | ----------------------------------------------------------------------- |
|
||||||
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` |
|
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
|
||||||
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) |
|
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
|
||||||
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption |
|
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
|
||||||
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
|
||||||
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
|
||||||
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, heatmap, cycle detection |
|
| | 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
|
## Architecture
|
||||||
|
|
||||||
### notfiles (primary user-facing tool)
|
### notfiles (primary user-facing tool)
|
||||||
|
|
||||||
Four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
|
||||||
|
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
|
||||||
|
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
||||||
|
|
||||||
**Core flow for `link`:** `main` → `resolve_packages` → `collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state).
|
**Core flow for `link`:** `main` → `config.validate()` →
|
||||||
|
`resolve_packages_filtered` (include/exclude + platform filtering) →
|
||||||
|
`collect_files` (recursive walk with ignore filtering) →
|
||||||
|
`linker::link_package` (create symlinks or copies, record in state,
|
||||||
|
return `LinkResult` with counters).
|
||||||
|
|
||||||
|
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
|
||||||
|
filesystem I/O; `Reporter` trait abstracts output. Adapters:
|
||||||
|
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
|
||||||
|
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
|
||||||
|
|
||||||
Key modules in `crates/notfiles/src/`:
|
Key modules in `crates/notfiles/src/`:
|
||||||
|
|
||||||
- **linker** — Creates/removes symlinks or copies. Manages `State` (`.notfiles-state.toml`) tracking every linked file. Handles conflict detection, `--force` backups, empty-parent cleanup on unlink.
|
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
|
||||||
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`.
|
Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
|
||||||
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`.
|
linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
|
||||||
|
- **package** — Discovers packages with include/exclude and platform
|
||||||
|
filtering. Recursively collects files via `IgnoreMatcher`. Typo
|
||||||
|
suggestions via `suggest_package` on not-found errors.
|
||||||
- **ignore** — Glob-based ignore matching using `globset`.
|
- **ignore** — Glob-based ignore matching using `globset`.
|
||||||
- **status** — Compares expected vs actual state: linked/copied/missing/conflict/orphan.
|
- **status** — Compares expected vs actual state:
|
||||||
|
linked/copied/missing/conflict/orphan. Also provides `diff_package`
|
||||||
|
for copy-method divergence detection.
|
||||||
|
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
|
||||||
|
`TerminalReporter`, `JsonReporter`.
|
||||||
|
- **ports** — `FileStore` trait definition.
|
||||||
|
|
||||||
### notsecrets
|
### notsecrets
|
||||||
|
|
||||||
`AgeKeySource` trait with three implementations: `BitwardenSource` (bw CLI), `FileSource`, `PromptSource`. `resolve_age_key()` tries each in order. `install_age_key()` writes to `~/.config/sops/age/keys.txt` (mode 0600). `decrypt_sops()` shells out to `sops --decrypt`.
|
Multi-provider secret resolution via `SecretResolver`. Providers include
|
||||||
|
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
|
||||||
|
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
|
||||||
|
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
|
||||||
|
`sops` or `age` binaries required.
|
||||||
|
|
||||||
### nothooks
|
### nothooks
|
||||||
|
|
||||||
@@ -63,7 +97,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory is a "package". Per-package config can override: `method` (symlink/copy), `target` directory, additional `ignore` patterns.
|
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
|
||||||
|
is a "package".
|
||||||
|
|
||||||
|
Global defaults support `include` (allowlist) or `exclude` (blocklist)
|
||||||
|
for package filtering — mutually exclusive. Per-package config can
|
||||||
|
override: `method` (symlink/copy), `target` directory, additional
|
||||||
|
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
|
||||||
|
gate packages to specific OSes.
|
||||||
|
|
||||||
## Edition
|
## Edition
|
||||||
|
|
||||||
|
|||||||
783
Cargo.lock
generated
783
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
36
Cargo.toml
@@ -1,5 +1,11 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/89jobrien/notfiles"
|
||||||
|
homepage = "https://github.com/89jobrien/notfiles"
|
||||||
|
keywords = ["dotfiles", "symlink", "stow", "config"]
|
||||||
|
categories = ["command-line-utilities", "filesystem"]
|
||||||
|
readme = "README.md"
|
||||||
|
rust-version = "1.87.0"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
@@ -7,31 +13,43 @@ members = [
|
|||||||
"crates/notfiles",
|
"crates/notfiles",
|
||||||
"crates/notsecrets",
|
"crates/notsecrets",
|
||||||
"crates/nothooks",
|
"crates/nothooks",
|
||||||
|
"crates/notnet",
|
||||||
"crates/notstrap",
|
"crates/notstrap",
|
||||||
"crates/notgraph",
|
"crates/notgraph",
|
||||||
|
"crates/notforge",
|
||||||
"tests/integration",
|
"tests/integration",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
assert_fs = "1"
|
||||||
|
base64 = "0.22"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
miette = { version = "7", features = ["derive"] }
|
||||||
thiserror = "2"
|
|
||||||
toml = "0.8"
|
|
||||||
which = "7"
|
|
||||||
rpassword = "7"
|
|
||||||
tempfile = "3"
|
|
||||||
assert_fs = "1"
|
|
||||||
notcore = { path = "crates/notcore" }
|
notcore = { path = "crates/notcore" }
|
||||||
notfiles = { path = "crates/notfiles" }
|
notfiles = { path = "crates/notfiles" }
|
||||||
notsecrets = { path = "crates/notsecrets" }
|
notforge = { path = "crates/notforge" }
|
||||||
nothooks = { path = "crates/nothooks" }
|
nothooks = { path = "crates/nothooks" }
|
||||||
|
notnet = { path = "crates/notnet" }
|
||||||
|
notsecrets = { path = "crates/notsecrets" }
|
||||||
|
notstrap = { path = "crates/notstrap", version = "^0.1.0" } #unified
|
||||||
|
rpassword = "7"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
strsim = "0.11"
|
||||||
|
tempfile = "3"
|
||||||
|
thiserror = "2"
|
||||||
|
toml = "0.8"
|
||||||
|
ureq = { version = "2", features = ["json"] }
|
||||||
|
which = "7"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
24
HANDOFF.md
24
HANDOFF.md
@@ -1,24 +0,0 @@
|
|||||||
# HANDOFF.md
|
|
||||||
|
|
||||||
Legacy note as of 2026-04-15.
|
|
||||||
|
|
||||||
This repo no longer uses `HANDOFF.md` as a running history log.
|
|
||||||
|
|
||||||
Active workflow:
|
|
||||||
|
|
||||||
1. Sync GitHub issues into `doob`
|
|
||||||
2. Read short-lived handoff context from `.ctx/HANDOFF.*.*.yaml`
|
|
||||||
3. Update issue state in `doob`
|
|
||||||
4. Sync `doob` back to GitHub issues
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Handoff context should be brief and ephemeral
|
|
||||||
- Only open work should appear in handoff files
|
|
||||||
- Closed issues should be removed from handoff files after sync
|
|
||||||
- Historical status belongs in GitHub issues and git history, not here
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
|
|
||||||
- No open handoff items are tracked locally
|
|
||||||
- Use GitHub issues plus `doob` as the task source of truth
|
|
||||||
3
Justfile
3
Justfile
@@ -14,6 +14,9 @@ ci:
|
|||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace -- -D warnings
|
||||||
cargo nextest run --workspace
|
cargo nextest run --workspace
|
||||||
|
|
||||||
|
install:
|
||||||
|
cargo install --path crates/notfiles --root "${HOME}/.local" --force
|
||||||
|
|
||||||
install-hooks:
|
install-hooks:
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
printf '#!/bin/sh\njust pre-commit\n' > .git/hooks/pre-commit
|
printf '#!/bin/sh\njust pre-commit\n' > .git/hooks/pre-commit
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -15,10 +15,24 @@ notfiles link
|
|||||||
# Check status
|
# Check status
|
||||||
notfiles status
|
notfiles status
|
||||||
|
|
||||||
|
# Validate config without changes (CI-friendly)
|
||||||
|
notfiles check
|
||||||
|
|
||||||
|
# Show copy-method divergence
|
||||||
|
notfiles diff
|
||||||
|
|
||||||
|
# Move an existing file into a package
|
||||||
|
notfiles adopt git .gitconfig
|
||||||
|
|
||||||
# Remove symlinks
|
# Remove symlinks
|
||||||
notfiles unlink
|
notfiles unlink
|
||||||
|
|
||||||
|
# Generate shell completions
|
||||||
|
notfiles completions bash > ~/.bash_completion.d/notfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All commands support `--dry-run`, `--verbose`, and `--json` flags.
|
||||||
|
|
||||||
On a new machine (once `notstrap` ships):
|
On a new machine (once `notstrap` ships):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,11 +47,13 @@ notstrap run
|
|||||||
```
|
```
|
||||||
notfiles/
|
notfiles/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── notcore/ # shared types, config, paths, errors
|
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
|
||||||
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
||||||
│ ├── notsecrets/ # age key retrieval + sops decrypt
|
│ ├── notsecrets/ # multi-provider secret resolution + age crypto
|
||||||
│ ├── nothooks/ # hook execution engine
|
│ ├── nothooks/ # hook execution engine
|
||||||
│ └── notstrap/ # new-machine bootstrap orchestrator
|
│ ├── notnet/ # network utilities (Tailscale, Yubikey)
|
||||||
|
│ ├── notstrap/ # new-machine bootstrap orchestrator
|
||||||
|
│ └── notgraph/ # Rust dependency graph visualizer
|
||||||
├── notfiles.toml # symlink package config
|
├── notfiles.toml # symlink package config
|
||||||
└── notstrap.toml # bootstrap hook/phase config
|
└── notstrap.toml # bootstrap hook/phase config
|
||||||
```
|
```
|
||||||
@@ -46,11 +62,13 @@ notfiles/
|
|||||||
|
|
||||||
| Crate | Type | Does |
|
| Crate | Type | Does |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates |
|
| `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
|
||||||
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state |
|
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
|
||||||
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets |
|
| `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
|
||||||
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
||||||
|
| `notnet` | lib | Tailscale integration, Yubikey identity source |
|
||||||
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
||||||
|
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
|
||||||
|
|
||||||
### Dependency graph
|
### Dependency graph
|
||||||
|
|
||||||
@@ -60,6 +78,7 @@ notstrap
|
|||||||
│ └── notcore
|
│ └── notcore
|
||||||
├── notsecrets
|
├── notsecrets
|
||||||
│ └── notcore
|
│ └── notcore
|
||||||
|
├── notnet
|
||||||
└── nothooks
|
└── nothooks
|
||||||
└── notcore
|
└── notcore
|
||||||
```
|
```
|
||||||
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
|
|||||||
```
|
```
|
||||||
notfiles link
|
notfiles link
|
||||||
│
|
│
|
||||||
├─ resolve_packages() discover subdirs or validate requested names
|
├─ config.validate() check include/exclude mutual exclusion
|
||||||
│
|
│
|
||||||
├─ collect_files() recursive walk, apply ignore patterns (globset)
|
├─ resolve_packages_filtered() include/exclude + platform filtering
|
||||||
│
|
│
|
||||||
├─ 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
|
### State file
|
||||||
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
|
|||||||
|
|
||||||
## Secrets Bootstrap Detail
|
## Secrets Bootstrap Detail
|
||||||
|
|
||||||
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order:
|
`notsecrets` implements a `SecretResolver` with pluggable provider sources:
|
||||||
|
|
||||||
```
|
```
|
||||||
AgeKeySource
|
SecretResolver
|
||||||
├── BitwardenSource bw unlock → session token → bw get item age-key
|
├── EnvSource read from environment variables
|
||||||
├── FileSource read from --key-file path (USB, etc.)
|
├── OpSource 1Password CLI (op read)
|
||||||
└── PromptSource read from stdin (paste)
|
├── BitwardenSource bw CLI
|
||||||
|
├── FileSource read from file path
|
||||||
|
├── DotenvxSource dotenvx encrypted .env files
|
||||||
|
├── SopsSource SOPS-encrypted files (native age decryption)
|
||||||
|
└── ... (extensible via SecretSource trait)
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and `sops` decrypts `secrets.sops.env`. The decrypted file contains all critical bootstrap credentials (op, bw, github, openai, anthropic, etc.) and is injected into the environment for subsequent hooks.
|
Age encryption/decryption is handled natively (no external `age` or `sops`
|
||||||
|
binaries). Supports x25519, SSH ed25519/RSA, and scrypt identities.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,10 +217,15 @@ Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and
|
|||||||
[defaults]
|
[defaults]
|
||||||
method = "symlink"
|
method = "symlink"
|
||||||
target = "~"
|
target = "~"
|
||||||
|
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
|
||||||
|
# exclude = ["scratch"] # or blocklist (mutually exclusive)
|
||||||
|
|
||||||
[packages.secrets]
|
[packages.secrets]
|
||||||
method = "copy" # copy instead of symlink for sensitive files
|
method = "copy" # copy instead of symlink for sensitive files
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"] # only link on Linux
|
||||||
|
|
||||||
[packages.work]
|
[packages.work]
|
||||||
target = "~/work" # different target dir
|
target = "~/work" # different target dir
|
||||||
ignore = ["*.local"]
|
ignore = ["*.local"]
|
||||||
@@ -233,6 +265,23 @@ Migration is gradual — shell scripts are replaced as Rust equivalents ship:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Absorbing `pj`
|
||||||
|
|
||||||
|
`pj` (89jobrien/pj) was a standalone portable dev-environment bootstrap CLI. Its functionality overlaps directly with this workspace:
|
||||||
|
|
||||||
|
| `pj` command | `notfiles` equivalent |
|
||||||
|
|---|---|
|
||||||
|
| `pj dot` | `notfiles` (symlink engine) |
|
||||||
|
| `pj doctor` | `notstrap` prerequisites check |
|
||||||
|
| `pj sync` | `notstrap run` |
|
||||||
|
| `pj secret` | `notsecrets` |
|
||||||
|
| `pj cache` | planned `notcache` or Cargo workspace tooling |
|
||||||
|
| `pj tui` | planned TUI layer on `notstrap` |
|
||||||
|
|
||||||
|
`pj` is archived. Its remaining functionality (cache management, TUI dashboard, git-config helper) will be folded in as those areas of the workspace mature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
93
cliff.toml
Normal file
93
cliff.toml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# git-cliff ~ configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "89jobrien"
|
||||||
|
repo = "notfiles"
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## Unreleased
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\
|
||||||
|
{{ commit.message | split(pat="\n") | first | upper_first | trim_end }}\
|
||||||
|
{% if commit.links %} \
|
||||||
|
({% for link in commit.links %}[#{{ link.text }}]({{ link.href }}){% if not loop.last %}, {% endif %}{% endfor %})\
|
||||||
|
{% endif %}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
|
||||||
|
footer = """
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{% for release in releases -%}
|
||||||
|
{% if release.version -%}
|
||||||
|
{% if release.previous.version -%}
|
||||||
|
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
||||||
|
{{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }}
|
||||||
|
{% endif -%}
|
||||||
|
{% else -%}
|
||||||
|
[unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}...HEAD
|
||||||
|
{% endif -%}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- generated by git-cliff -->
|
||||||
|
"""
|
||||||
|
|
||||||
|
trim = true
|
||||||
|
render_always = true
|
||||||
|
output = "CHANGELOG.md"
|
||||||
|
|
||||||
|
postprocessors = [
|
||||||
|
{ pattern = '<REPO>', replace = "https://github.com/89jobrien/notfiles" },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
protect_breaking_commits = false
|
||||||
|
filter_commits = false
|
||||||
|
fail_on_unmatched_commit = false
|
||||||
|
use_branch_tags = false
|
||||||
|
topo_order = false
|
||||||
|
topo_order_commits = true
|
||||||
|
sort_commits = "oldest"
|
||||||
|
recurse_submodules = false
|
||||||
|
|
||||||
|
commit_preprocessors = [
|
||||||
|
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))" },
|
||||||
|
]
|
||||||
|
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^doc", group = "Documentation" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^style", group = "Styling" },
|
||||||
|
{ message = "^test", group = "Testing" },
|
||||||
|
{ message = "^chore\\(release\\):.*", skip = true },
|
||||||
|
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pr\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pull\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous" },
|
||||||
|
{ body = ".*security", group = "Security" },
|
||||||
|
{ message = "^revert", group = "Reverts" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
link_parsers = [
|
||||||
|
{ pattern = "#(\\d+)", href = "https://github.com/89jobrien/notfiles/issues/$1", text = "$1" },
|
||||||
|
]
|
||||||
@@ -3,10 +3,15 @@ name = "notcore"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
description = "Shared types and config for the notfiles dotfiles manager"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
strsim = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::NotfilesError;
|
use crate::NotfilesError;
|
||||||
|
|
||||||
@@ -18,6 +19,14 @@ pub struct Defaults {
|
|||||||
pub target: String,
|
pub target: String,
|
||||||
#[serde(default = "default_ignore")]
|
#[serde(default = "default_ignore")]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If set, only these package names are discovered.
|
||||||
|
/// Mutually exclusive with `exclude`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include: Vec<String>,
|
||||||
|
/// If set, these package names are skipped during discovery.
|
||||||
|
/// Mutually exclusive with `include`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Defaults {
|
impl Default for Defaults {
|
||||||
@@ -25,6 +34,8 @@ impl Default for Defaults {
|
|||||||
Self {
|
Self {
|
||||||
target: default_target(),
|
target: default_target(),
|
||||||
ignore: default_ignore(),
|
ignore: default_ignore(),
|
||||||
|
include: Vec::new(),
|
||||||
|
exclude: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +63,10 @@ pub struct PackageConfig {
|
|||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If non-empty, this package is only linked on these platforms.
|
||||||
|
/// Valid values: "macos", "linux".
|
||||||
|
#[serde(default)]
|
||||||
|
pub platforms: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -71,13 +86,44 @@ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default config path: `~/.config/notfiles/notfiles.toml`.
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||||
|
.join("notfiles")
|
||||||
|
.join("notfiles.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default dotfiles directory: `~/.notfiles`.
|
||||||
|
pub fn default_dotfiles_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
|
.join(".notfiles")
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
/// Load config from an explicit path. Returns `Config::default()` if the file does not exist.
|
||||||
let config_path = dotfiles_dir.join("notfiles.toml");
|
pub fn load_from(config_path: &Path) -> Result<Self, NotfilesError> {
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
return Ok(Config::default());
|
return Ok(Config::default());
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&config_path).map_err(|e| {
|
let content = std::fs::read_to_string(config_path).map_err(|e| {
|
||||||
NotfilesError::Config(format!("reading {}: {e}", config_path.display()))
|
NotfilesError::Config(format!("reading {}: {e}", config_path.display()))
|
||||||
})?;
|
})?;
|
||||||
let config: Config = toml::from_str(&content).map_err(|e| {
|
let config: Config = toml::from_str(&content).map_err(|e| {
|
||||||
@@ -86,6 +132,12 @@ impl Config {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load config from `<dotfiles_dir>/notfiles.toml` (legacy location).
|
||||||
|
/// Prefer [`Config::load_from`] with an explicit path in new call sites.
|
||||||
|
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
||||||
|
Self::load_from(&dotfiles_dir.join("notfiles.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn method_for(&self, package: &str) -> Method {
|
pub fn method_for(&self, package: &str) -> Method {
|
||||||
self.packages
|
self.packages
|
||||||
.get(package)
|
.get(package)
|
||||||
@@ -100,6 +152,50 @@ impl Config {
|
|||||||
.unwrap_or(&self.defaults.target)
|
.unwrap_or(&self.defaults.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the include list if non-empty, or None (meaning all).
|
||||||
|
pub fn included_packages(&self) -> Option<Vec<&str>> {
|
||||||
|
if self.defaults.include.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut v: Vec<&str> = self.defaults.include.iter().map(|s| s.as_str()).collect();
|
||||||
|
v.sort();
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this package name appears in the exclude list.
|
||||||
|
pub fn is_package_excluded(&self, name: &str) -> bool {
|
||||||
|
self.defaults.exclude.iter().any(|e| e == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate config invariants. Returns Err if include and exclude
|
||||||
|
/// are both non-empty.
|
||||||
|
pub fn validate(&self) -> Result<(), NotfilesError> {
|
||||||
|
if !self.defaults.include.is_empty() && !self.defaults.exclude.is_empty() {
|
||||||
|
return Err(NotfilesError::Validation(
|
||||||
|
"include and exclude are mutually exclusive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the package should be linked on the current OS.
|
||||||
|
/// Empty platforms list means "all platforms".
|
||||||
|
pub fn is_package_for_current_platform(&self, package: &str) -> bool {
|
||||||
|
let platforms = match self.packages.get(package) {
|
||||||
|
Some(pkg) if !pkg.platforms.is_empty() => &pkg.platforms,
|
||||||
|
_ => return true,
|
||||||
|
};
|
||||||
|
let current = if cfg!(target_os = "macos") {
|
||||||
|
"macos"
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
"linux"
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
platforms.iter().any(|p| p == current)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
||||||
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
||||||
if let Some(pkg) = self.packages.get(package) {
|
if let Some(pkg) = self.packages.get(package) {
|
||||||
@@ -111,6 +207,23 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suggest the closest package name if the user made a typo.
|
||||||
|
/// Returns None if no close match (distance > 2).
|
||||||
|
pub fn suggest_package<'a>(name: &str, available: &[&'a str]) -> Option<&'a str> {
|
||||||
|
available
|
||||||
|
.iter()
|
||||||
|
.filter_map(|candidate| {
|
||||||
|
let dist = strsim::damerau_levenshtein(name, candidate);
|
||||||
|
if dist <= 2 {
|
||||||
|
Some((*candidate, dist))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.min_by_key(|(_, d)| *d)
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn starter_toml() -> &'static str {
|
pub fn starter_toml() -> &'static str {
|
||||||
r#"[defaults]
|
r#"[defaults]
|
||||||
target = "~"
|
target = "~"
|
||||||
@@ -129,6 +242,10 @@ ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfil
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_config_path(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("notcore-{name}-{}.toml", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
@@ -138,6 +255,92 @@ mod tests {
|
|||||||
assert_eq!(config.target_for("anything"), "~");
|
assert_eq!(config.target_for("anything"), "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_filter_restricts_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
include = ["git", "zsh", "nushell"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.included_packages(),
|
||||||
|
Some(vec!["git", "nushell", "zsh"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exclude_filter_blocks_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
exclude = ["scripts", "docs", "tests"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.is_package_excluded("scripts"));
|
||||||
|
assert!(!config.is_package_excluded("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_and_exclude_mutual_exclusion() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git"]
|
||||||
|
exclude = ["scripts"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_include_no_exclude_returns_none() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.included_packages(), None);
|
||||||
|
assert!(!config.is_package_excluded("anything"));
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_linux_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"]
|
||||||
|
|
||||||
|
[packages.git]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
// On macOS this should be false, on Linux true
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(!config.is_package_for_current_platform("nixos"));
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
assert!(config.is_package_for_current_platform("nixos"));
|
||||||
|
}
|
||||||
|
// git has no platform filter — always matches
|
||||||
|
assert!(config.is_package_for_current_platform("git"));
|
||||||
|
// unknown package — no config entry, always matches
|
||||||
|
assert!(config.is_package_for_current_platform("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_macos_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[packages.vscode]
|
||||||
|
platforms = ["macos"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(config.is_package_for_current_platform("vscode"));
|
||||||
|
} else {
|
||||||
|
assert!(!config.is_package_for_current_platform("vscode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_config() {
|
fn test_parse_config() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
@@ -162,4 +365,40 @@ target = "~/bin"
|
|||||||
assert!(ssh_ignores.contains(&".git"));
|
assert!(ssh_ignores.contains(&".git"));
|
||||||
assert!(ssh_ignores.contains(&"known_hosts"));
|
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}")]
|
#[error("state file error: {0}")]
|
||||||
State(String),
|
State(String),
|
||||||
|
|
||||||
|
#[error("glob error: {0}")]
|
||||||
|
Glob(String),
|
||||||
|
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
//! Core types and configuration for the notfiles dotfiles manager.
|
||||||
|
//!
|
||||||
|
//! This crate provides shared types used across the notfiles workspace:
|
||||||
|
//! configuration parsing, error types, path utilities, and the reporter
|
||||||
|
//! trait for output abstraction.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod reporter;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use config::{Config, Defaults, Method, PackageConfig};
|
pub use config::{Config, Defaults, Method, PackageConfig, suggest_package};
|
||||||
pub use error::NotfilesError;
|
pub use error::NotfilesError;
|
||||||
pub use paths::{dotfiles_dir, expand_tilde};
|
pub use paths::{dotfiles_dir, expand_tilde};
|
||||||
|
pub use reporter::{LinkEvent, Reporter, SilentReporter};
|
||||||
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
||||||
|
|||||||
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"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -14,15 +18,17 @@ name = "notfiles"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
|
||||||
clap = { workspace = true }
|
|
||||||
globset = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
clap_complete = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
globset = { workspace = true }
|
||||||
|
notcore = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
|
||||||
assert_fs = { 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 fs;
|
||||||
|
pub mod memory;
|
||||||
|
pub mod reporter;
|
||||||
|
|
||||||
pub use fs::FileStoreImpl;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,14 @@ use std::path::PathBuf;
|
|||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "notfiles", about = "A modern dotfiles manager")]
|
#[command(name = "notfiles", about = "A modern dotfiles manager")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Path to the dotfiles directory (default: current directory)
|
/// Path to the dotfiles directory (default: ~/.notfiles)
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub dir: Option<PathBuf>,
|
pub dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Path to the config file (default: ~/.config/notfiles/notfiles.toml)
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
/// Show what would be done without making changes
|
/// Show what would be done without making changes
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
@@ -16,10 +20,39 @@ pub struct Cli {
|
|||||||
#[arg(long, short, global = true)]
|
#[arg(long, short, global = true)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
|
||||||
|
/// Output in JSON format
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub json: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(install): `notfiles` is not yet installed on PATH. Add `just install` recipe and
|
||||||
|
// `mise task` so `nf` / `notfiles` is available without `cargo run`. See Justfile.
|
||||||
|
|
||||||
|
// TODO(bootstrap): Wire `notstrap` into the CLI as `notfiles bootstrap`. Should clone the
|
||||||
|
// repo, resolve age key (Bitwarden/YubiKey/prompt), decrypt SOPS secrets, link packages,
|
||||||
|
// and run post-hooks. The notstrap crate already models this — needs a CLI entry point.
|
||||||
|
|
||||||
|
// TODO(migrate): Add `notfiles migrate <dir>` that takes a stow/chezmoi repo (from `detect`)
|
||||||
|
// and generates a notfiles.toml for it, then bulk-adopts all packages. Should call
|
||||||
|
// `detect::detect()`, let the user pick a source, and run `adopt` for each package.
|
||||||
|
|
||||||
|
// TODO(which): Add `notfiles which <path>` — reverse lookup from a target path (e.g.
|
||||||
|
// ~/.gitconfig) to its source package and file. Walk state entries and check read_link.
|
||||||
|
|
||||||
|
// TODO(doctor): Add `notfiles doctor` — holistic drift detection replacing drift-check.sh.
|
||||||
|
// Checks: broken symlinks, unmanaged dotfiles in $HOME, dirty git state, conflicts,
|
||||||
|
// packages in dir not listed in notfiles.toml. Should print a summary like `just doctor`.
|
||||||
|
|
||||||
|
// TODO(discover): Add `[defaults] discover = true` mode to notfiles.toml so all top-level
|
||||||
|
// dirs are treated as packages automatically, without maintaining an explicit `include`
|
||||||
|
// list. Opt-in via config, not the default (explicit is safer).
|
||||||
|
|
||||||
|
// TODO(edit): Add `notfiles edit <managed-path>` — resolve the source file for a managed
|
||||||
|
// symlink and open it in $EDITOR. Quality-of-life shortcut for day-to-day editing.
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Create a starter notfiles.toml
|
/// Create a starter notfiles.toml
|
||||||
@@ -50,4 +83,31 @@ pub enum Command {
|
|||||||
/// Specific packages to check (default: all)
|
/// Specific packages to check (default: all)
|
||||||
packages: Vec<String>,
|
packages: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Validate config without making changes (CI-friendly)
|
||||||
|
Check,
|
||||||
|
|
||||||
|
/// Generate shell completions
|
||||||
|
Completions {
|
||||||
|
/// Shell to generate for
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: clap_complete::Shell,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show differences between source and target for copy-method packages
|
||||||
|
Diff {
|
||||||
|
/// Specific packages to diff (default: all copy-method packages)
|
||||||
|
packages: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Auto-detect existing dotfile managers (stow, chezmoi, etc.)
|
||||||
|
Detect,
|
||||||
|
|
||||||
|
/// Move existing files into a package and replace with symlinks
|
||||||
|
Adopt {
|
||||||
|
/// Package to adopt files into
|
||||||
|
package: String,
|
||||||
|
/// File paths (relative to target dir) to adopt
|
||||||
|
files: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
let glob = Glob::new(pattern)
|
||||||
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}"))
|
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
|
||||||
})?;
|
})?;
|
||||||
builder.add(glob);
|
builder.add(glob);
|
||||||
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
||||||
@@ -28,7 +28,7 @@ impl IgnoreMatcher {
|
|||||||
}
|
}
|
||||||
let globset = builder
|
let globset = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
|
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
|
||||||
Ok(Self { globset })
|
Ok(Self { globset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,107 @@
|
|||||||
|
//! A modern dotfiles manager — pure Rust alternative to GNU Stow.
|
||||||
|
//!
|
||||||
|
//! Symlinks (or copies) files from organized "package" directories
|
||||||
|
//! into a target location (typically `~`). Supports include/exclude
|
||||||
|
//! filtering, platform gates, and multiple output formats.
|
||||||
|
//!
|
||||||
|
//! # Quick start
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use notfiles::{link, LinkOptions};
|
||||||
|
//! use std::path::Path;
|
||||||
|
//!
|
||||||
|
//! let opts = LinkOptions {
|
||||||
|
//! force: false,
|
||||||
|
//! no_backup: false,
|
||||||
|
//! dry_run: false,
|
||||||
|
//! verbose: true,
|
||||||
|
//! };
|
||||||
|
//! let state = link(Path::new("/home/user/dotfiles"), &[], &opts)
|
||||||
|
//! .expect("link failed");
|
||||||
|
//! ```
|
||||||
|
|
||||||
pub mod adapters;
|
pub mod adapters;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod detect;
|
||||||
pub mod ignore;
|
pub mod ignore;
|
||||||
pub mod linker;
|
pub mod linker;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use notcore::NotfilesError;
|
||||||
|
use notcore::reporter::Reporter;
|
||||||
|
|
||||||
pub use adapters::FileStoreImpl;
|
pub use adapters::FileStoreImpl;
|
||||||
pub use linker::{LinkOptions, State};
|
pub use linker::{LinkOptions, LinkResult, State};
|
||||||
pub use package::{resolve_packages, resolve_packages_with_store};
|
pub use package::{
|
||||||
|
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
|
||||||
|
resolve_packages_with_store,
|
||||||
|
};
|
||||||
pub use ports::FileStore;
|
pub use ports::FileStore;
|
||||||
|
|
||||||
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
/// Link all (or specified) packages using the real filesystem and
|
||||||
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
/// terminal output.
|
||||||
|
pub fn link(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
packages: &[String],
|
||||||
|
opts: &LinkOptions,
|
||||||
|
) -> Result<State, NotfilesError> {
|
||||||
|
let reporter = adapters::TerminalReporter;
|
||||||
|
link_with_store(
|
||||||
|
dotfiles_dir,
|
||||||
|
packages,
|
||||||
|
opts,
|
||||||
|
&adapters::FileStoreImpl,
|
||||||
|
&reporter,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Link packages with injectable filesystem and reporter (for testing).
|
||||||
pub fn link_with_store(
|
pub fn link_with_store(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
packages: &[String],
|
packages: &[String],
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<State> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<State, NotfilesError> {
|
||||||
let config = notcore::Config::load(dotfiles_dir)?;
|
let config = notcore::Config::load(dotfiles_dir)?;
|
||||||
|
config.validate()?;
|
||||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||||
let pkgs = resolve_packages_with_store(dotfiles_dir, packages, fs)?;
|
let pkgs = package::resolve_packages_filtered_with_store(dotfiles_dir, packages, &config, fs)?;
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?;
|
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs, reporter)?;
|
||||||
}
|
}
|
||||||
state.save(dotfiles_dir, fs)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
|
/// Remove managed symlinks/copies for all (or specified) packages.
|
||||||
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
pub fn unlink(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
packages: &[String],
|
||||||
|
opts: &LinkOptions,
|
||||||
|
) -> Result<(), NotfilesError> {
|
||||||
|
let reporter = adapters::TerminalReporter;
|
||||||
|
unlink_with_store(
|
||||||
|
dotfiles_dir,
|
||||||
|
packages,
|
||||||
|
opts,
|
||||||
|
&adapters::FileStoreImpl,
|
||||||
|
&reporter,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unlink packages with injectable filesystem and reporter.
|
||||||
pub fn unlink_with_store(
|
pub fn unlink_with_store(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
packages: &[String],
|
packages: &[String],
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<()> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<(), NotfilesError> {
|
||||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||||
let pkgs = if packages.is_empty() {
|
let pkgs = if packages.is_empty() {
|
||||||
state
|
state
|
||||||
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
|
|||||||
packages.to_vec()
|
packages.to_vec()
|
||||||
};
|
};
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs)?;
|
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs, reporter)?;
|
||||||
}
|
}
|
||||||
state.save(dotfiles_dir, fs)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::package::collect_files_with_store;
|
use crate::package::collect_files_with_store;
|
||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
|
use notcore::reporter::{LinkEvent, Reporter};
|
||||||
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||||
|
|
||||||
const STATE_FILE: &str = ".notfiles-state.toml";
|
const STATE_FILE: &str = ".notfiles-state.toml";
|
||||||
@@ -81,6 +82,15 @@ pub struct LinkOptions {
|
|||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LinkResult {
|
||||||
|
pub package: String,
|
||||||
|
pub linked: usize,
|
||||||
|
pub copied: usize,
|
||||||
|
pub skipped: usize,
|
||||||
|
pub backed_up: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn link_package(
|
pub fn link_package(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -88,17 +98,23 @@ pub fn link_package(
|
|||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<(), NotfilesError> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<LinkResult, NotfilesError> {
|
||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
let method = config.method_for(package);
|
let method = config.method_for(package);
|
||||||
let target_base = expand_tilde(config.target_for(package))?;
|
let target_base = expand_tilde(config.target_for(package))?;
|
||||||
let files = collect_files_with_store(&package_dir, config, package, fs)?;
|
let files = collect_files_with_store(&package_dir, config, package, fs)?;
|
||||||
|
let mut result = LinkResult {
|
||||||
|
package: package.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: no files to link");
|
source: package,
|
||||||
}
|
reason: "no files to link",
|
||||||
return Ok(());
|
});
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
for relative in &files {
|
for relative in &files {
|
||||||
@@ -108,19 +124,22 @@ pub fn link_package(
|
|||||||
|
|
||||||
// Check if already correctly linked / copied
|
// Check if already correctly linked / copied
|
||||||
if is_already_linked(&source, &target, method, fs) {
|
if is_already_linked(&source, &target, method, fs) {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
|
source: &source_display,
|
||||||
}
|
reason: "already linked",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: save partial state then return an error.
|
// Helper: save partial state then return an error.
|
||||||
let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> {
|
let save_and_return =
|
||||||
if !opts.dry_run {
|
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
|
||||||
let _ = state.save(dotfiles_dir, fs);
|
if !opts.dry_run {
|
||||||
}
|
let _ = state.save(dotfiles_dir, fs);
|
||||||
Err(e)
|
}
|
||||||
};
|
Err(e)
|
||||||
|
};
|
||||||
|
|
||||||
// Conflict detection
|
// Conflict detection
|
||||||
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
||||||
@@ -139,22 +158,20 @@ pub fn link_package(
|
|||||||
if !opts.no_backup {
|
if !opts.no_backup {
|
||||||
let backup = backup_path(&target);
|
let backup = backup_path(&target);
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(
|
reporter.report(&LinkEvent::DryRun {
|
||||||
" \x1b[33mwould backup\x1b[0m {} -> {}",
|
action: "backup",
|
||||||
target.display(),
|
source: &source_display,
|
||||||
backup.display()
|
target: &backup,
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Backup {
|
||||||
println!(
|
from: &target,
|
||||||
" \x1b[33mbackup\x1b[0m {} -> {}",
|
to: &backup,
|
||||||
target.display(),
|
});
|
||||||
backup.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(e) = fs.rename(&target, &backup) {
|
if let Err(e) = fs.rename(&target, &backup) {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
|
result.backed_up += 1;
|
||||||
}
|
}
|
||||||
} else if !opts.dry_run {
|
} else if !opts.dry_run {
|
||||||
let rm_result = if fs.is_dir(&target) {
|
let rm_result = if fs.is_dir(&target) {
|
||||||
@@ -171,9 +188,7 @@ pub fn link_package(
|
|||||||
// Create parent directories
|
// Create parent directories
|
||||||
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
|
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::CreateDir { path: parent });
|
||||||
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
|
|
||||||
}
|
|
||||||
} else if let Err(e) = fs.create_dir_all(parent) {
|
} else if let Err(e) = fs.create_dir_all(parent) {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
@@ -186,12 +201,13 @@ pub fn link_package(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(
|
reporter.report(&LinkEvent::DryRun {
|
||||||
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
|
action: action_word,
|
||||||
target.display()
|
source: &source_display,
|
||||||
);
|
target: &target,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
let link_result = match method {
|
let io_result = match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -210,14 +226,24 @@ pub fn link_package(
|
|||||||
fs.write(&target, &content).map(|_| content.len() as u64)
|
fs.write(&target, &content).map(|_| content.len() as u64)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = link_result {
|
if let Err(e) = io_result {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
if opts.verbose {
|
match method {
|
||||||
println!(
|
Method::Symlink => {
|
||||||
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
|
reporter.report(&LinkEvent::Link {
|
||||||
target.display()
|
source: &source_display,
|
||||||
);
|
target: &target,
|
||||||
|
});
|
||||||
|
result.linked += 1;
|
||||||
|
}
|
||||||
|
Method::Copy => {
|
||||||
|
reporter.report(&LinkEvent::Copy {
|
||||||
|
source: &source_display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
result.copied += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.add_entry(StateEntry {
|
state.add_entry(StateEntry {
|
||||||
@@ -233,7 +259,7 @@ pub fn link_package(
|
|||||||
if !opts.dry_run {
|
if !opts.dry_run {
|
||||||
state.save(dotfiles_dir, fs)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink_package(
|
pub fn unlink_package(
|
||||||
@@ -242,6 +268,7 @@ pub fn unlink_package(
|
|||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
) -> Result<(), NotfilesError> {
|
) -> Result<(), NotfilesError> {
|
||||||
// Validate the package: it must either exist as a directory in dotfiles_dir
|
// Validate the package: it must either exist as a directory in dotfiles_dir
|
||||||
// or have entries in state. A name that satisfies neither is a user error.
|
// or have entries in state. A name that satisfies neither is a user error.
|
||||||
@@ -260,9 +287,10 @@ pub fn unlink_package(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: nothing to unlink");
|
source: package,
|
||||||
}
|
reason: "nothing to unlink",
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +301,10 @@ pub fn unlink_package(
|
|||||||
let source = PathBuf::from(&entry.source);
|
let source = PathBuf::from(&entry.source);
|
||||||
|
|
||||||
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
}
|
reason: "already gone",
|
||||||
|
});
|
||||||
if !opts.dry_run {
|
if !opts.dry_run {
|
||||||
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
||||||
}
|
}
|
||||||
@@ -287,64 +316,59 @@ pub fn unlink_package(
|
|||||||
// Verify it's a symlink pointing to our source
|
// Verify it's a symlink pointing to our source
|
||||||
if let Ok(link_target) = fs.read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
if link_target != source {
|
if link_target != source {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
|
reason: "symlink points elsewhere",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
}
|
reason: "not a symlink",
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
||||||
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
|
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
|
||||||
(Ok(_), Ok(_)) => {
|
(Ok(_), Ok(_)) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (copied file diverged from source)",
|
reason: "copied file diverged from source",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
(Err(_), _) => {
|
(Err(_), _) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (source missing for copied file)",
|
reason: "source missing for copied file",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
(_, Err(_)) => {
|
(_, Err(_)) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (cannot read copied target)",
|
reason: "cannot read copied target",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(" \x1b[36mwould remove\x1b[0m {}", target.display());
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "remove",
|
||||||
|
source: &target.to_string_lossy(),
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if fs.is_dir(&target) {
|
if fs.is_dir(&target) {
|
||||||
fs.remove_dir_all(&target)?;
|
fs.remove_dir_all(&target)?;
|
||||||
} else {
|
} else {
|
||||||
fs.remove_file(&target)?;
|
fs.remove_file(&target)?;
|
||||||
}
|
}
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Remove { target: &target });
|
||||||
println!(" \x1b[31mremove\x1b[0m {}", target.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up empty parent dirs
|
// Clean up empty parent dirs
|
||||||
cleanup_empty_parents(&target, fs);
|
cleanup_empty_parents(&target, fs);
|
||||||
@@ -362,6 +386,100 @@ pub fn unlink_package(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn adopt_files(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
state: &mut State,
|
||||||
|
package: &str,
|
||||||
|
files: &[String],
|
||||||
|
opts: &LinkOptions,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<LinkResult, NotfilesError> {
|
||||||
|
let package_dir = dotfiles_dir.join(package);
|
||||||
|
let target_base = expand_tilde(config.target_for(package))?;
|
||||||
|
let mut result = LinkResult {
|
||||||
|
package: package.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !fs.is_dir(&package_dir) {
|
||||||
|
if opts.dry_run {
|
||||||
|
reporter.report(&LinkEvent::CreateDir { path: &package_dir });
|
||||||
|
} else {
|
||||||
|
fs.create_dir_all(&package_dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let relative = PathBuf::from(file);
|
||||||
|
let target = target_base.join(&relative);
|
||||||
|
let source = package_dir.join(&relative);
|
||||||
|
let display = format!("{package}/{}", relative.display());
|
||||||
|
|
||||||
|
if !fs.exists(&target) {
|
||||||
|
reporter.report(&LinkEvent::Skip {
|
||||||
|
source: &display,
|
||||||
|
reason: "target file does not exist",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.exists(&source) {
|
||||||
|
reporter.report(&LinkEvent::Skip {
|
||||||
|
source: &display,
|
||||||
|
reason: "already exists in package",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.dry_run {
|
||||||
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "adopt",
|
||||||
|
source: &display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent dirs in package
|
||||||
|
if let Some(parent) = source.parent().filter(|p| !fs.exists(p)) {
|
||||||
|
fs.create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move target -> package source
|
||||||
|
fs.rename(&target, &source)?;
|
||||||
|
|
||||||
|
// Create symlink target -> source
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
fs.symlink(&source, &target)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter.report(&LinkEvent::Link {
|
||||||
|
source: &display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.add_entry(StateEntry {
|
||||||
|
package: package.to_string(),
|
||||||
|
source: source.to_string_lossy().to_string(),
|
||||||
|
target: target.to_string_lossy().to_string(),
|
||||||
|
method: Method::Symlink,
|
||||||
|
linked_at: Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
result.linked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.dry_run {
|
||||||
|
state.save(dotfiles_dir, fs)?;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
|
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
|
||||||
match method {
|
match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
|
|||||||
@@ -1,32 +1,69 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::{CommandFactory, Parser};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use notcore::Config;
|
use notcore::Config;
|
||||||
|
use notcore::reporter::Reporter;
|
||||||
|
use notfiles::adapters::{JsonReporter, TerminalReporter};
|
||||||
use notfiles::cli::{Cli, Command};
|
use notfiles::cli::{Cli, Command};
|
||||||
|
use notfiles::detect;
|
||||||
use notfiles::linker::{LinkOptions, State};
|
use notfiles::linker::{LinkOptions, State};
|
||||||
use notfiles::package::resolve_packages_with_store;
|
use notfiles::package::{resolve_packages_filtered_with_store, resolve_packages_with_store};
|
||||||
use notfiles::{adapters, linker, status};
|
use notfiles::{adapters, linker, status};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let dotfiles_dir = cli
|
let dotfiles_dir = cli
|
||||||
.dir
|
.dir
|
||||||
.unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory"));
|
.unwrap_or_else(notcore::config::default_dotfiles_dir);
|
||||||
|
|
||||||
|
// Init is the only command allowed when the dotfiles dir doesn't exist yet.
|
||||||
|
if matches!(cli.command, Command::Init) {
|
||||||
|
return cmd_init(&dotfiles_dir, cli.config.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dotfiles_dir.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"dotfiles directory not found: {}\nRun `notfiles init` to create it.",
|
||||||
|
dotfiles_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
||||||
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
||||||
|
|
||||||
|
let config_path = cli
|
||||||
|
.config
|
||||||
|
.unwrap_or_else(notcore::config::default_config_path);
|
||||||
|
|
||||||
|
let reporter: Box<dyn Reporter> = if cli.json {
|
||||||
|
Box::new(JsonReporter)
|
||||||
|
} else {
|
||||||
|
Box::new(TerminalReporter)
|
||||||
|
};
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Init => cmd_init(&dotfiles_dir)?,
|
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 => unreachable!("handled above"),
|
||||||
Command::Link {
|
Command::Link {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
packages,
|
packages,
|
||||||
} => {
|
} => {
|
||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load_from(&config_path)?;
|
||||||
|
config.validate()?;
|
||||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||||
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?;
|
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||||
let opts = LinkOptions {
|
let opts = LinkOptions {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
@@ -38,19 +75,26 @@ fn main() -> Result<()> {
|
|||||||
println!("\x1b[36m(dry run)\x1b[0m");
|
println!("\x1b[36m(dry run)\x1b[0m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Linking {pkg}...");
|
println!("Linking {pkg}...");
|
||||||
}
|
}
|
||||||
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts, fs)?;
|
let result = linker::link_package(
|
||||||
|
&dotfiles_dir,
|
||||||
|
&config,
|
||||||
|
&mut state,
|
||||||
|
pkg,
|
||||||
|
&opts,
|
||||||
|
fs,
|
||||||
|
&*reporter,
|
||||||
|
)?;
|
||||||
|
results.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.dry_run {
|
if !cli.dry_run {
|
||||||
state.save(&dotfiles_dir, fs)?;
|
state.save(&dotfiles_dir, fs)?;
|
||||||
let count: usize = pkgs
|
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
|
||||||
.iter()
|
|
||||||
.map(|p| state.entries_for_package(p).len())
|
|
||||||
.sum();
|
|
||||||
println!(
|
println!(
|
||||||
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
||||||
if count == 1 { "" } else { "s" },
|
if count == 1 { "" } else { "s" },
|
||||||
@@ -61,7 +105,7 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Command::Unlink { packages } => {
|
Command::Unlink { packages } => {
|
||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load_from(&config_path)?;
|
||||||
let _ = &config; // loaded but not needed for unlink
|
let _ = &config; // loaded but not needed for unlink
|
||||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||||
let pkgs = if packages.is_empty() {
|
let pkgs = if packages.is_empty() {
|
||||||
@@ -95,7 +139,7 @@ fn main() -> Result<()> {
|
|||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Unlinking {pkg}...");
|
println!("Unlinking {pkg}...");
|
||||||
}
|
}
|
||||||
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs)?;
|
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.dry_run {
|
if !cli.dry_run {
|
||||||
@@ -107,15 +151,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_from(&config_path)?;
|
||||||
|
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_from(&config_path)?;
|
||||||
|
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_from(&config_path)?;
|
||||||
|
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||||
|
let opts = LinkOptions {
|
||||||
|
force: false,
|
||||||
|
no_backup: false,
|
||||||
|
dry_run: cli.dry_run,
|
||||||
|
verbose: cli.verbose,
|
||||||
|
};
|
||||||
|
|
||||||
|
if cli.dry_run {
|
||||||
|
println!("\x1b[36m(dry run)\x1b[0m");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = linker::adopt_files(
|
||||||
|
&dotfiles_dir,
|
||||||
|
&config,
|
||||||
|
&mut state,
|
||||||
|
&package,
|
||||||
|
&files,
|
||||||
|
&opts,
|
||||||
|
fs,
|
||||||
|
&*reporter,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !cli.dry_run {
|
||||||
|
println!(
|
||||||
|
"\x1b[32mAdopted {} file{} into {package}.\x1b[0m",
|
||||||
|
result.linked,
|
||||||
|
if result.linked == 1 { "" } else { "s" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Status { packages } => {
|
Command::Status { packages } => {
|
||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load_from(&config_path)?;
|
||||||
|
config.validate()?;
|
||||||
let state = State::load(&dotfiles_dir, fs)?;
|
let state = State::load(&dotfiles_dir, fs)?;
|
||||||
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?;
|
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||||
|
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
|
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
|
||||||
status::print_status(pkg, &entries);
|
if cli.json {
|
||||||
|
status::print_status_json(pkg, &entries);
|
||||||
|
} else {
|
||||||
|
status::print_status(pkg, &entries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,13 +252,29 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> {
|
fn cmd_init(
|
||||||
let config_path = dotfiles_dir.join("notfiles.toml");
|
dotfiles_dir: &std::path::Path,
|
||||||
|
config_override: Option<&std::path::Path>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !dotfiles_dir.exists() {
|
||||||
|
fs::create_dir_all(dotfiles_dir)
|
||||||
|
.with_context(|| format!("creating dotfiles dir: {}", dotfiles_dir.display()))?;
|
||||||
|
println!("Created {}", dotfiles_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = config_override
|
||||||
|
.map(std::path::Path::to_path_buf)
|
||||||
|
.unwrap_or_else(notcore::config::default_config_path);
|
||||||
|
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
println!("notfiles.toml already exists.");
|
println!("{} already exists.", config_path.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("creating config dir: {}", parent.display()))?;
|
||||||
|
}
|
||||||
fs::write(&config_path, notcore::config::starter_toml())?;
|
fs::write(&config_path, notcore::config::starter_toml())?;
|
||||||
println!("Created notfiles.toml");
|
println!("Created {}", config_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ use crate::ignore::IgnoreMatcher;
|
|||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
use notcore::{Config, NotfilesError};
|
use notcore::{Config, NotfilesError};
|
||||||
|
|
||||||
|
/// Discover packages filtered by the config's include/exclude lists and
|
||||||
|
/// platform constraints.
|
||||||
|
pub fn discover_packages_filtered(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
discover_packages_filtered_with_store(dotfiles_dir, config, &FileStoreImpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_packages_filtered_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
let all = discover_packages_with_store(dotfiles_dir, fs)?;
|
||||||
|
let filtered: Vec<String> = if let Some(include) = config.included_packages() {
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|p| include.contains(&p.as_str()))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|p| !config.is_package_excluded(p))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
// Apply platform filter
|
||||||
|
let filtered = filtered
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| config.is_package_for_current_platform(p))
|
||||||
|
.collect();
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
/// Discover available packages (subdirectories of the dotfiles dir).
|
/// Discover available packages (subdirectories of the dotfiles dir).
|
||||||
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
||||||
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
||||||
@@ -48,13 +80,28 @@ pub fn resolve_packages_with_store(
|
|||||||
requested: &[String],
|
requested: &[String],
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<Vec<String>, NotfilesError> {
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
let available = discover_packages_with_store(dotfiles_dir, fs)?;
|
resolve_packages_filtered_with_store(dotfiles_dir, requested, &Config::default(), fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_packages_filtered_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
requested: &[String],
|
||||||
|
config: &Config,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
let available = discover_packages_filtered_with_store(dotfiles_dir, config, fs)?;
|
||||||
if requested.is_empty() {
|
if requested.is_empty() {
|
||||||
return Ok(available);
|
return Ok(available);
|
||||||
}
|
}
|
||||||
for name in requested {
|
for name in requested {
|
||||||
if !available.contains(name) {
|
if !available.contains(name) {
|
||||||
return Err(NotfilesError::PackageNotFound { name: name.clone() });
|
let available_refs: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
|
||||||
|
let suggestion = notcore::suggest_package(name, &available_refs);
|
||||||
|
let mut msg = name.clone();
|
||||||
|
if let Some(s) = suggestion {
|
||||||
|
msg = format!("{name} (did you mean '{s}'?)");
|
||||||
|
}
|
||||||
|
return Err(NotfilesError::PackageNotFound { name: msg });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(requested.to_vec())
|
Ok(requested.to_vec())
|
||||||
@@ -123,6 +170,40 @@ mod tests {
|
|||||||
assert_eq!(pkgs, vec!["git", "zsh"]);
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_include_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("zsh")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git", "zsh"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_exclude_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
exclude = ["scripts", "docs"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_specific_packages() {
|
fn test_resolve_specific_packages() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::linker::State;
|
use crate::linker::State;
|
||||||
use crate::package::collect_files_with_store;
|
use crate::package::collect_files_with_store;
|
||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
@@ -121,6 +123,126 @@ pub fn package_status(
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum DiffKind {
|
||||||
|
Identical,
|
||||||
|
Modified,
|
||||||
|
SourceOnly,
|
||||||
|
TargetOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiffEntry {
|
||||||
|
pub source_display: String,
|
||||||
|
pub target: PathBuf,
|
||||||
|
pub kind: DiffKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff_package(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
state: &State,
|
||||||
|
package: &str,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Vec<DiffEntry> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let method = config.method_for(package);
|
||||||
|
if method != Method::Copy {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_dir = dotfiles_dir.join(package);
|
||||||
|
let target_base = match expand_tilde(config.target_for(package)) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return results,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check files tracked in state for this package
|
||||||
|
for entry in state.entries_for_package(package) {
|
||||||
|
let source = PathBuf::from(&entry.source);
|
||||||
|
let target = PathBuf::from(&entry.target);
|
||||||
|
let source_display = format!(
|
||||||
|
"{package}/{}",
|
||||||
|
source
|
||||||
|
.strip_prefix(&package_dir)
|
||||||
|
.unwrap_or(&source)
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let kind = match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) => {
|
||||||
|
if src == tgt {
|
||||||
|
DiffKind::Identical
|
||||||
|
} else {
|
||||||
|
DiffKind::Modified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Ok(_), Err(_)) => DiffKind::SourceOnly,
|
||||||
|
(Err(_), Ok(_)) => DiffKind::TargetOnly,
|
||||||
|
(Err(_), Err(_)) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check untracked files from the package dir
|
||||||
|
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
|
||||||
|
for relative in &files {
|
||||||
|
let source = package_dir.join(relative);
|
||||||
|
let target = target_base.join(relative);
|
||||||
|
// Skip if already covered by state entries
|
||||||
|
if results.iter().any(|r| r.target == target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source_display = format!("{package}/{}", relative.display());
|
||||||
|
let kind = if !fs.exists(&target) {
|
||||||
|
DiffKind::SourceOnly
|
||||||
|
} else {
|
||||||
|
match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
|
||||||
|
(Ok(_), Ok(_)) => DiffKind::Modified,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
|
||||||
|
let non_identical: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind != DiffKind::Identical)
|
||||||
|
.collect();
|
||||||
|
if non_identical.is_empty() {
|
||||||
|
println!(" {package}: all copies identical");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(" \x1b[1m{package}\x1b[0m:");
|
||||||
|
for entry in &non_identical {
|
||||||
|
let label = match entry.kind {
|
||||||
|
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
|
||||||
|
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
|
||||||
|
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
|
||||||
|
DiffKind::Identical => unreachable!(),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {label} {} -> {}",
|
||||||
|
entry.source_display,
|
||||||
|
entry.target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
println!(" {package}: (empty)");
|
println!(" {package}: (empty)");
|
||||||
@@ -136,3 +258,18 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn print_status_json(package: &str, entries: &[StatusEntry]) {
|
||||||
|
let items: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
json!({
|
||||||
|
"source": e.source_display,
|
||||||
|
"target": e.target.to_string_lossy(),
|
||||||
|
"status": format!("{:?}", e.status).to_lowercase(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let obj = json!({"package": package, "entries": items});
|
||||||
|
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -14,9 +14,12 @@ fn notfiles_bin() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) {
|
fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) {
|
||||||
|
let config = dotfiles.join("notfiles.toml");
|
||||||
let output = Command::new(notfiles_bin())
|
let output = Command::new(notfiles_bin())
|
||||||
.arg("--dir")
|
.arg("--dir")
|
||||||
.arg(dotfiles)
|
.arg(dotfiles)
|
||||||
|
.arg("--config")
|
||||||
|
.arg(&config)
|
||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run notfiles");
|
.expect("failed to run notfiles");
|
||||||
@@ -61,7 +64,7 @@ fn test_init_creates_config() {
|
|||||||
|
|
||||||
let (stdout, _, ok) = run(&dotfiles, &["init"]);
|
let (stdout, _, ok) = run(&dotfiles, &["init"]);
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
assert!(stdout.contains("Created notfiles.toml"));
|
assert!(stdout.contains("Created"), "expected Created in: {stdout}");
|
||||||
assert!(dotfiles.join("notfiles.toml").exists());
|
assert!(dotfiles.join("notfiles.toml").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,3 +466,164 @@ fn test_unlink_cleans_empty_dirs() {
|
|||||||
// The .config/zsh directory should be cleaned up
|
// The .config/zsh directory should be cleaned up
|
||||||
assert!(!target.join(".config/zsh").exists());
|
assert!(!target.join(".config/zsh").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fixture-based tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Copy the static fixture into a temp dir with a rewritten target, so tests
|
||||||
|
/// don't touch the real home directory.
|
||||||
|
fn setup_from_fixture(tmp: &TempDir) {
|
||||||
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dotfiles");
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
fs::create_dir_all(&target).unwrap();
|
||||||
|
copy_dir_recursive(&fixture, &dotfiles).unwrap();
|
||||||
|
|
||||||
|
// Rewrite notfiles.toml to point target at our temp home
|
||||||
|
let config_path = dotfiles.join("notfiles.toml");
|
||||||
|
let content = fs::read_to_string(&config_path).unwrap();
|
||||||
|
let rewritten = content.replace(
|
||||||
|
"target = \"~\"",
|
||||||
|
&format!("target = \"{}\"", target.display()),
|
||||||
|
);
|
||||||
|
fs::write(&config_path, rewritten).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(entry.file_name());
|
||||||
|
if src_path.is_dir() {
|
||||||
|
copy_dir_recursive(&src_path, &dst_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_include_excludes_non_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Stow packages should be linked
|
||||||
|
assert!(
|
||||||
|
target.join(".gitconfig").exists(),
|
||||||
|
".gitconfig should be linked"
|
||||||
|
);
|
||||||
|
assert!(target.join(".zshrc").exists(), ".zshrc should be linked");
|
||||||
|
assert!(
|
||||||
|
target.join(".config/nushell/config.nu").exists(),
|
||||||
|
"nushell config should be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
target.join(".config/starship.toml").exists(),
|
||||||
|
"starship config should be linked",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-package dirs should NOT be linked
|
||||||
|
assert!(
|
||||||
|
!target.join("lib/common.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("bootstrap-runbook.md").exists(),
|
||||||
|
"docs/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("doctor.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_status_shows_all_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
|
||||||
|
let (stdout, _, ok) = run(&dotfiles, &["status"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Should list stow packages
|
||||||
|
assert!(stdout.contains("git"), "status should show git package");
|
||||||
|
assert!(stdout.contains("zsh"), "status should show zsh package");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("nushell"),
|
||||||
|
"status should show nushell package"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT list excluded dirs
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("scripts"),
|
||||||
|
"status should not show scripts"
|
||||||
|
);
|
||||||
|
assert!(!stdout.contains("docs"), "status should not show docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_deep_paths_link_correctly() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link", "vscode"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
let vscode_settings = target.join("Library/Application Support/Code/User/settings.json");
|
||||||
|
assert!(
|
||||||
|
vscode_settings.exists(),
|
||||||
|
"deep path should be linked: {}",
|
||||||
|
vscode_settings.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_platform_filter_skips_linux_on_macos() {
|
||||||
|
if !cfg!(target_os = "macos") {
|
||||||
|
return; // This test is macOS-specific
|
||||||
|
}
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// nixos has platforms = ["linux"], so it should NOT be linked on macOS
|
||||||
|
assert!(
|
||||||
|
!target.join("configuration.nix").exists(),
|
||||||
|
"nixos package should be skipped on macOS",
|
||||||
|
);
|
||||||
|
|
||||||
|
// But macos-compatible packages should be linked
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_link_and_unlink_round_trip() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
// Link all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
|
||||||
|
// Unlink all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["unlink"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(!target.join(".gitconfig").exists());
|
||||||
|
assert!(!target.join(".zshrc").exists());
|
||||||
|
}
|
||||||
|
|||||||
31
crates/notforge/Cargo.toml
Normal file
31
crates/notforge/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[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]
|
||||||
|
base64 = { 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]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
cargo_metadata = "0.18"
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
cargo_metadata = "0.18"
|
|
||||||
syn = { version = "2", features = ["full", "visit"] }
|
syn = { version = "2", features = ["full", "visit"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
|
use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
|
||||||
|
use std::cmp::Reverse;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> {
|
pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> {
|
||||||
@@ -66,7 +67,7 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
|
|||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
let mut by_fan_in = stats.to_vec();
|
let mut by_fan_in = stats.to_vec();
|
||||||
by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in));
|
by_fan_in.sort_by_key(|b| Reverse(b.fan_in));
|
||||||
for s in by_fan_in.iter().take(top_n) {
|
for s in by_fan_in.iter().take(top_n) {
|
||||||
if s.fan_in > 0 {
|
if s.fan_in > 0 {
|
||||||
result.push(Hotspot {
|
result.push(Hotspot {
|
||||||
@@ -78,7 +79,7 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut by_fan_out = stats.to_vec();
|
let mut by_fan_out = stats.to_vec();
|
||||||
by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out));
|
by_fan_out.sort_by_key(|b| Reverse(b.fan_out));
|
||||||
for s in by_fan_out.iter().take(top_n) {
|
for s in by_fan_out.iter().take(top_n) {
|
||||||
if s.fan_out > 0 {
|
if s.fan_out > 0 {
|
||||||
result.push(Hotspot {
|
result.push(Hotspot {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ name = "nothooks"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
notcore = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
clap = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
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,28 @@ edition = "2024"
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
|
||||||
anyhow = { 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"
|
base64 = "0.22"
|
||||||
hmac = "0.12"
|
bech32 = "0.11"
|
||||||
rand = "0.8"
|
chacha20poly1305 = "0.10"
|
||||||
zeroize = "1"
|
|
||||||
curve25519-dalek = "4"
|
curve25519-dalek = "4"
|
||||||
|
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 }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
yubikey = ["dep:yubikey"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
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}")]
|
#[error("identity source failed ({name}): {source}")]
|
||||||
SourceError { name: String, source: anyhow::Error },
|
SourceError { name: String, source: anyhow::Error },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SecretsError {
|
||||||
|
#[error("[{name}] {source}")]
|
||||||
|
SourceError { name: String, source: anyhow::Error },
|
||||||
|
#[error("no provider resolved key: {key}")]
|
||||||
|
NotFound { key: String },
|
||||||
|
#[error("config error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_error_display_source_error() {
|
||||||
|
let err = SecretsError::SourceError {
|
||||||
|
name: "op".to_string(),
|
||||||
|
source: anyhow::anyhow!("not found"),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("[op]"));
|
||||||
|
assert!(err.to_string().contains("not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_error_display_not_found() {
|
||||||
|
let err = SecretsError::NotFound {
|
||||||
|
key: "DB_URL".to_string(),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("DB_URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_error_display_config() {
|
||||||
|
let err = SecretsError::Config("bad toml".to_string());
|
||||||
|
assert!(err.to_string().contains("bad toml"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ pub use ssh_ed25519::SshEd25519Identity;
|
|||||||
pub mod scrypt;
|
pub mod scrypt;
|
||||||
pub use scrypt::ScryptIdentity;
|
pub use scrypt::ScryptIdentity;
|
||||||
|
|
||||||
pub mod ssh_rsa;
|
|
||||||
pub use ssh_rsa::SshRsaIdentity;
|
|
||||||
|
|
||||||
pub mod encrypted;
|
pub mod encrypted;
|
||||||
pub use encrypted::EncryptedIdentity;
|
pub use encrypted::EncryptedIdentity;
|
||||||
|
|
||||||
|
|||||||
@@ -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 decrypt;
|
||||||
pub mod encrypt;
|
pub mod encrypt;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -5,20 +6,25 @@ pub mod format;
|
|||||||
pub mod identities;
|
pub mod identities;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod recipients;
|
pub mod recipients;
|
||||||
|
pub mod resolver;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
|
|
||||||
|
pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config};
|
||||||
pub use decrypt::Decryptor;
|
pub use decrypt::Decryptor;
|
||||||
pub use encrypt::Encryptor;
|
pub use encrypt::Encryptor;
|
||||||
pub use error::AgeError;
|
pub use error::AgeError;
|
||||||
|
pub use error::SecretsError;
|
||||||
pub use identities::{
|
pub use identities::{
|
||||||
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
|
||||||
SshRsaIdentity, Stanza, X25519Identity,
|
X25519Identity,
|
||||||
};
|
};
|
||||||
pub use ports::IdentitySource;
|
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
|
||||||
pub use recipients::{
|
pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, X25519Recipient};
|
||||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
pub use resolver::SecretResolver;
|
||||||
|
pub use sources::{
|
||||||
|
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,
|
||||||
|
MiseSource, NuenvSource, OpSource, PromptSource, SopsSource, VaultSource, YubikeySource,
|
||||||
};
|
};
|
||||||
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
|
||||||
|
|
||||||
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::error::AgeError;
|
use crate::config::{Provider, SecretRef};
|
||||||
|
use crate::error::{AgeError, SecretsError};
|
||||||
use crate::identities::Identity;
|
use crate::identities::Identity;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Infra boundary trait: an identity resolver that loads key material from an
|
/// Infra boundary trait: an identity resolver that loads key material from an
|
||||||
/// external source and returns a concrete Identity.
|
/// external source and returns a concrete Identity.
|
||||||
@@ -10,3 +12,51 @@ pub trait IdentitySource {
|
|||||||
/// Load and return a concrete identity from the source.
|
/// Load and return a concrete identity from the source.
|
||||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
|
fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Port: resolve a single secret by key name from an external provider.
|
||||||
|
pub trait SecretSource {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn provider(&self) -> Provider;
|
||||||
|
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
|
||||||
|
|
||||||
|
/// Resolve using a provider-specific typed reference.
|
||||||
|
/// Default: ignores ref, falls back to resolve(key).
|
||||||
|
fn resolve_ref(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
_secret_ref: &SecretRef,
|
||||||
|
) -> Result<Option<String>, SecretsError> {
|
||||||
|
self.resolve(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extended port: sources that can enumerate all available secrets.
|
||||||
|
pub trait EnumerableSecretSource: SecretSource {
|
||||||
|
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct FakeSource;
|
||||||
|
impl SecretSource for FakeSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fake"
|
||||||
|
}
|
||||||
|
fn provider(&self) -> Provider {
|
||||||
|
Provider::Env
|
||||||
|
}
|
||||||
|
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
|
||||||
|
Ok(Some("val".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_resolve_ref_delegates_to_resolve() {
|
||||||
|
let source = FakeSource;
|
||||||
|
let secret_ref = SecretRef::Env;
|
||||||
|
let result = source.resolve_ref("KEY", &secret_ref).unwrap();
|
||||||
|
assert_eq!(result, Some("val".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ pub use ssh_ed25519::SshEd25519Recipient;
|
|||||||
pub mod scrypt;
|
pub mod scrypt;
|
||||||
pub use scrypt::ScryptRecipient;
|
pub use scrypt::ScryptRecipient;
|
||||||
|
|
||||||
pub mod ssh_rsa;
|
|
||||||
pub use ssh_rsa::SshRsaRecipient;
|
|
||||||
|
|
||||||
/// Domain port: a recipient that can wrap a file key into a stanza.
|
/// Domain port: a recipient that can wrap a file key into a stanza.
|
||||||
pub trait Recipient {
|
pub trait Recipient {
|
||||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
||||||
|
|||||||
@@ -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::Identity;
|
||||||
use crate::identities::x25519::X25519Identity;
|
use crate::identities::x25519::X25519Identity;
|
||||||
use crate::ports::IdentitySource;
|
use crate::ports::{IdentitySource, SecretSource};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ impl BitwardenSource {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !bw_ok {
|
if !bw_ok {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw CLI not found in PATH"),
|
source: anyhow::anyhow!("bw CLI not found in PATH"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ impl BitwardenSource {
|
|||||||
let password =
|
let password =
|
||||||
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
|
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
|
||||||
AgeError::SourceError {
|
AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("could not read password: {e}"),
|
source: anyhow::anyhow!("could not read password: {e}"),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -46,26 +47,26 @@ impl BitwardenSource {
|
|||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
stdin
|
stdin
|
||||||
.write_all(password.as_bytes())
|
.write_all(password.as_bytes())
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let output = child
|
let output = child
|
||||||
.wait_with_output()
|
.wait_with_output()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!(
|
source: anyhow::anyhow!(
|
||||||
"bw unlock failed: {}",
|
"bw unlock failed: {}",
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
@@ -74,7 +75,7 @@ impl BitwardenSource {
|
|||||||
}
|
}
|
||||||
String::from_utf8(output.stdout)
|
String::from_utf8(output.stdout)
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
||||||
})?
|
})?
|
||||||
.trim()
|
.trim()
|
||||||
@@ -90,12 +91,12 @@ impl BitwardenSource {
|
|||||||
.args(["get", "notes", &self.item_name, "--session", &session])
|
.args(["get", "notes", &self.item_name, "--session", &session])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw get spawn: {e}"),
|
source: anyhow::anyhow!("bw get spawn: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!(
|
source: anyhow::anyhow!(
|
||||||
"bw get notes '{}' failed: {}",
|
"bw get notes '{}' failed: {}",
|
||||||
self.item_name,
|
self.item_name,
|
||||||
@@ -105,14 +106,14 @@ impl BitwardenSource {
|
|||||||
}
|
}
|
||||||
let key = String::from_utf8(output.stdout)
|
let key = String::from_utf8(output.stdout)
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
||||||
})?
|
})?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
|
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,20 @@ impl BitwardenSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SecretSource for BitwardenSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"bitwarden"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider(&self) -> Provider {
|
||||||
|
Provider::Bitwarden
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IdentitySource for BitwardenSource {
|
impl IdentitySource for BitwardenSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"bitwarden"
|
"bitwarden"
|
||||||
@@ -129,7 +144,7 @@ impl IdentitySource for BitwardenSource {
|
|||||||
let key = self.retrieve_key()?;
|
let key = self.retrieve_key()?;
|
||||||
let identity =
|
let identity =
|
||||||
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(Box::new(identity))
|
Ok(Box::new(identity))
|
||||||
|
|||||||
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 bitwarden;
|
||||||
|
pub mod direnv;
|
||||||
|
pub mod dotenvx;
|
||||||
|
pub mod dotenvy;
|
||||||
|
pub mod env;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
|
pub mod gsm;
|
||||||
|
pub mod mise;
|
||||||
|
pub mod nuenv;
|
||||||
|
pub mod op;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
pub mod sops;
|
||||||
|
pub mod vault;
|
||||||
|
pub mod yubikey;
|
||||||
|
|
||||||
pub use crate::ports::IdentitySource;
|
pub use crate::ports::IdentitySource;
|
||||||
pub use bitwarden::BitwardenSource;
|
pub use bitwarden::BitwardenSource;
|
||||||
|
pub use direnv::DirenvSource;
|
||||||
|
pub use dotenvx::DotenvxSource;
|
||||||
|
pub use dotenvy::DotenvySource;
|
||||||
|
pub use env::EnvSource;
|
||||||
pub use file::FileSource;
|
pub use file::FileSource;
|
||||||
|
pub use gsm::GsmSource;
|
||||||
|
pub use mise::MiseSource;
|
||||||
|
pub use nuenv::NuenvSource;
|
||||||
|
pub use op::OpSource;
|
||||||
pub use prompt::PromptSource;
|
pub use prompt::PromptSource;
|
||||||
|
pub use sops::SopsSource;
|
||||||
|
pub use vault::VaultSource;
|
||||||
|
pub use yubikey::YubikeySource;
|
||||||
|
|||||||
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"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
notcore = { workspace = true }
|
notcore = { workspace = true }
|
||||||
notfiles = { workspace = true }
|
notfiles = { workspace = true }
|
||||||
notsecrets = { workspace = true }
|
|
||||||
nothooks = { workspace = true }
|
nothooks = { workspace = true }
|
||||||
clap = { workspace = true }
|
notnet = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
notsecrets = { workspace = true }
|
||||||
which = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
which = { workspace = true }
|
||||||
|
|||||||
@@ -2,27 +2,32 @@ use anyhow::{Context, Result};
|
|||||||
use notcore::{HookPhase, Report, StepStatus};
|
use notcore::{HookPhase, Report, StepStatus};
|
||||||
use notfiles::{LinkOptions, link};
|
use notfiles::{LinkOptions, link};
|
||||||
use nothooks::{HookRunner, run_phase};
|
use nothooks::{HookRunner, run_phase};
|
||||||
use notsecrets::identities::Identity;
|
|
||||||
use notsecrets::sources::IdentitySource;
|
use notsecrets::sources::IdentitySource;
|
||||||
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
|
use notsecrets::{
|
||||||
|
BitwardenSource, FileSource, PromptSource, SecretResolver, YubikeySource, resolve_identities,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub mod prereqs;
|
pub mod prereqs;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
|
|
||||||
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
|
pub use notnet::TailscaleOptions;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NotstrapConfig {
|
pub struct NotstrapConfig {
|
||||||
pub bootstrap: BootstrapSection,
|
pub bootstrap: BootstrapSection,
|
||||||
|
/// When present, notstrap joins the tailnet before cloning dotfiles.
|
||||||
|
pub tailscale: Option<TailscaleOptions>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hooks: Vec<notcore::HookSpec>,
|
pub hooks: Vec<notcore::HookSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct BootstrapSection {
|
pub struct BootstrapSection {
|
||||||
pub dotfiles_repo: String,
|
/// Required when `[tailscale]` is absent. When `[tailscale]` is present,
|
||||||
|
/// `gitea_url` from that section is used as the clone URL instead.
|
||||||
|
pub dotfiles_repo: Option<String>,
|
||||||
pub dotfiles_dir: String,
|
pub dotfiles_dir: String,
|
||||||
#[serde(default = "default_bw_item")]
|
#[serde(default = "default_bw_item")]
|
||||||
pub bw_age_item: String,
|
pub bw_age_item: String,
|
||||||
@@ -42,10 +47,16 @@ pub struct BootstrapOptions {
|
|||||||
pub force: bool,
|
pub force: bool,
|
||||||
pub key_file: Option<PathBuf>,
|
pub key_file: Option<PathBuf>,
|
||||||
pub dotfiles: Option<PathBuf>,
|
pub dotfiles: Option<PathBuf>,
|
||||||
|
/// Override the Tailscale options from the config file. `None` here defers to the
|
||||||
|
/// config; use `Some(None)` to explicitly skip Tailscale even when the config has
|
||||||
|
/// a `[tailscale]` section (useful in tests and CI).
|
||||||
|
///
|
||||||
|
/// Encoding: `None` = use config, `Some(None)` = skip, `Some(Some(opts))` = override.
|
||||||
|
pub tailscale: Option<Option<TailscaleOptions>>,
|
||||||
/// None = skip prereq check (tests). Some(f) = run f().
|
/// None = skip prereq check (tests). Some(f) = run f().
|
||||||
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
|
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
|
||||||
/// None = skip env injection (tests). Some(f) = decrypt sops at path and inject.
|
/// Path to notsecrets.toml. None = skip secret resolution.
|
||||||
pub env_injector: Option<EnvInjector>,
|
pub secrets_config: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||||
@@ -76,8 +87,37 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Clone dotfiles if missing
|
// Resolve which Tailscale options to use: CLI override > config > None.
|
||||||
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
|
let ts_opts: Option<TailscaleOptions> = match opts.tailscale {
|
||||||
|
Some(override_val) => override_val, // Some(None) = skip, Some(Some(o)) = use override
|
||||||
|
None => cfg.tailscale.clone(), // defer to config
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve dotfiles clone URL: Tailscale gitea_url takes precedence over dotfiles_repo.
|
||||||
|
let clone_url: Option<String> = ts_opts
|
||||||
|
.as_ref()
|
||||||
|
.map(|ts| ts.gitea_url.clone())
|
||||||
|
.or_else(|| cfg.bootstrap.dotfiles_repo.clone());
|
||||||
|
|
||||||
|
// 3. Tailscale (inserted before clone, skipped when ts_opts is None)
|
||||||
|
if let Some(ref ts) = ts_opts {
|
||||||
|
match notnet::ensure_connected(ts) {
|
||||||
|
Ok(true) => report.add("tailscale", StepStatus::Ok),
|
||||||
|
Ok(false) => report.add("tailscale", StepStatus::Skipped),
|
||||||
|
Err(e) => {
|
||||||
|
report.add("tailscale", StepStatus::Failed(e.to_string()));
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Clone dotfiles if missing
|
||||||
|
let clone_url = clone_url.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"no dotfiles clone URL: set [bootstrap].dotfiles_repo or add a [tailscale] section"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match repo::clone_if_missing(&clone_url, &dotfiles_dir) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
report.add("clone dotfiles", StepStatus::Ok);
|
report.add("clone dotfiles", StepStatus::Ok);
|
||||||
}
|
}
|
||||||
@@ -90,17 +130,18 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Resolve age identities
|
// 5. Resolve age identities
|
||||||
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
|
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
|
||||||
vec![Box::new(FileSource::new(kf))]
|
vec![Box::new(FileSource::new(kf))]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
|
Box::new(YubikeySource),
|
||||||
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
||||||
Box::new(PromptSource),
|
Box::new(PromptSource),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
let identities = match resolve_identities(sources) {
|
let _identities = match resolve_identities(sources) {
|
||||||
Ok(ids) => {
|
Ok(ids) => {
|
||||||
report.add("age key", StepStatus::Ok);
|
report.add("age key", StepStatus::Ok);
|
||||||
ids
|
ids
|
||||||
@@ -111,33 +152,44 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Decrypt sops secrets (optional)
|
// 5b. Resolve secrets via notsecrets.toml (optional)
|
||||||
if let Some(injector) = opts.env_injector {
|
if let Some(secrets_path) = opts.secrets_config {
|
||||||
let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file);
|
match notsecrets::load_config(&secrets_path) {
|
||||||
match injector(&sops_path, identities) {
|
Ok(secrets_cfg) => match SecretResolver::from_config(secrets_cfg) {
|
||||||
Ok(env_content) => {
|
Ok(resolver) => match resolver.resolve_all() {
|
||||||
for line in env_content.lines() {
|
Ok(env_map) => {
|
||||||
match parse_env_line(line) {
|
for (k, v) in &env_map {
|
||||||
Ok(Some((k, v))) => {
|
match parse_env_line(&format!("{k}={v}")) {
|
||||||
// SAFETY: `notstrap` is a single-threaded bootstrap binary; no other
|
Ok(Some((k, v))) => {
|
||||||
// threads exist. Subsequent Command::spawn calls (git, hooks) will
|
// SAFETY: `notstrap` is a single-threaded bootstrap
|
||||||
// inherit these vars — keys are validated by parse_env_line before
|
// binary; no other threads exist. Keys are validated
|
||||||
// reaching this point (null bytes and dangerous keys are rejected).
|
// by parse_env_line (null bytes and dangerous keys
|
||||||
unsafe {
|
// are rejected).
|
||||||
std::env::set_var(&k, &v);
|
unsafe {
|
||||||
|
std::env::set_var(&k, &v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
report.add("secrets", StepStatus::Failed(e.to_string()));
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
report.add("secrets", StepStatus::Ok);
|
||||||
Err(e) => {
|
|
||||||
report.add("decrypt secrets", StepStatus::Failed(e.to_string()));
|
|
||||||
return Ok(report);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
report.add("secrets", StepStatus::Failed(e.to_string()));
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
report.add("secrets", StepStatus::Failed(e.to_string()));
|
||||||
|
return Ok(report);
|
||||||
}
|
}
|
||||||
report.add("decrypt secrets", StepStatus::Ok);
|
},
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
report.add("decrypt secrets", StepStatus::Failed(e.to_string()));
|
report.add("secrets", StepStatus::Failed(e.to_string()));
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ fn main() -> Result<()> {
|
|||||||
force,
|
force,
|
||||||
key_file,
|
key_file,
|
||||||
dotfiles,
|
dotfiles,
|
||||||
|
tailscale: None, // defer to [tailscale] section in config (if present)
|
||||||
check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
|
check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
|
||||||
env_injector: None,
|
secrets_config: None,
|
||||||
};
|
};
|
||||||
let report = run(opts)?;
|
let report = run(opts)?;
|
||||||
report.print();
|
report.print();
|
||||||
|
|||||||
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
@@ -0,0 +1,141 @@
|
|||||||
|
# Tailscale Integration Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-16
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make notfiles environments ephemeral but reliable. A fresh machine runs `notstrap` and gets a
|
||||||
|
fully configured environment — no public GitHub exposure for dotfiles, no manual Tailscale setup,
|
||||||
|
no cloud dependency for secrets on bootstrap day.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a `notnet` crate that owns Tailscale network presence, extend `notsecrets` with a
|
||||||
|
`YubikeySource`, and thread both into `notstrap`'s orchestration sequence. The dotfiles repo lives
|
||||||
|
on a private Gitea instance on minibox, reachable only over Tailscale.
|
||||||
|
|
||||||
|
## Crate Responsibilities
|
||||||
|
|
||||||
|
### `notnet` (new)
|
||||||
|
|
||||||
|
Owns all Tailscale concerns:
|
||||||
|
|
||||||
|
- Detect if Tailscale is already running (`tailscale status`)
|
||||||
|
- Install Tailscale if missing (package manager detection: `apt`/`brew`/`pacman`, fallback to
|
||||||
|
official install script)
|
||||||
|
- Resolve the auth key via priority chain (see below)
|
||||||
|
- Join the tailnet (`tailscale up --authkey=<key>`)
|
||||||
|
- Verify the target peer is reachable (ping by Tailscale hostname)
|
||||||
|
|
||||||
|
**Auth key resolution chain:**
|
||||||
|
|
||||||
|
1. `TS_AUTHKEY` env var
|
||||||
|
2. YubiKey PIV slot 9d (via `yubikey` crate)
|
||||||
|
3. Interactive prompt
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct TailscaleOptions {
|
||||||
|
pub peer_hostname: String,
|
||||||
|
pub gitea_url: String,
|
||||||
|
pub install: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure this machine is connected to the tailnet and the target peer is reachable.
|
||||||
|
/// Returns immediately if Tailscale is already running.
|
||||||
|
pub fn ensure_connected(opts: &TailscaleOptions) -> Result<()>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `notsecrets` (extended)
|
||||||
|
|
||||||
|
New `YubikeySource` implementing `IdentitySource`:
|
||||||
|
|
||||||
|
- Uses the `yubikey` crate (pure Rust, no `ykman` dependency)
|
||||||
|
- Opens the first connected YubiKey
|
||||||
|
- Reads age private key material from PIV slot 9c
|
||||||
|
- Returns `Box<dyn Identity>`
|
||||||
|
|
||||||
|
Updated source resolution order in notstrap:
|
||||||
|
|
||||||
|
1. `--key-file` CLI arg
|
||||||
|
2. `YubikeySource` (PIV slot 9c)
|
||||||
|
3. `BitwardenSource`
|
||||||
|
4. `PromptSource`
|
||||||
|
|
||||||
|
### `notstrap` (updated)
|
||||||
|
|
||||||
|
`BootstrapOptions` gains:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub tailscale: Option<TailscaleOptions>,
|
||||||
|
```
|
||||||
|
|
||||||
|
`None` = skip notnet entirely (existing machines, tests, CI).
|
||||||
|
|
||||||
|
`notstrap.toml` gains a `[tailscale]` section. When present, `gitea_url` is used as the clone
|
||||||
|
URL instead of `[bootstrap].dotfiles_repo`.
|
||||||
|
|
||||||
|
## YubiKey PIV Slot Layout
|
||||||
|
|
||||||
|
| Slot | Purpose | Managed by |
|
||||||
|
|------|----------------------------------|-------------|
|
||||||
|
| 9a | SSH authentication | 1Password |
|
||||||
|
| 9c | Age private key (dotfiles/SOPS) | notsecrets |
|
||||||
|
| 9d | Tailscale auth key | notnet |
|
||||||
|
|
||||||
|
Slot 9a is untouched — 1Password agent remains the primary SSH auth for day-to-day use.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`notstrap.toml` example with Tailscale enabled:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[bootstrap]
|
||||||
|
dotfiles_dir = "~/dotfiles"
|
||||||
|
bw_age_item = "age-key-dotfiles"
|
||||||
|
sops_file = "secrets/bootstrap.sops.env"
|
||||||
|
# dotfiles_repo is optional when [tailscale] is present
|
||||||
|
|
||||||
|
[tailscale]
|
||||||
|
peer_hostname = "minibox"
|
||||||
|
gitea_url = "http://minibox:3000/joe/dotfiles.git"
|
||||||
|
install = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Bootstrap Sequence
|
||||||
|
|
||||||
|
| Step | Crate | Skip condition |
|
||||||
|
|-------------------|---------------|---------------------------------------|
|
||||||
|
| 1. Prerequisites | notstrap | — |
|
||||||
|
| 2. Tailscale | notnet | Already on tailnet |
|
||||||
|
| 3. Clone dotfiles | notstrap/repo | Already cloned |
|
||||||
|
| 4. Age key | notsecrets | — |
|
||||||
|
| 5. Decrypt SOPS | notstrap | No injector configured |
|
||||||
|
| 6. Link dotfiles | notfiles | — |
|
||||||
|
| 7. Dot hooks | nothooks | — |
|
||||||
|
| 8. Setup hooks | nothooks | Already run (state file) |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If `TS_AUTHKEY` is unset and no YubiKey is connected and user declines prompt → clear error,
|
||||||
|
bootstrap aborts at step 2
|
||||||
|
- If Tailscale installs but peer is unreachable → error naming the peer hostname, bootstrap aborts
|
||||||
|
- If YubiKey slot 9c is empty → `YubikeySource` returns `Err`, resolver falls through to
|
||||||
|
`BitwardenSource`
|
||||||
|
- All steps follow the existing `StepStatus::Failed` / early-return pattern in notstrap
|
||||||
|
|
||||||
|
## Dependencies (new)
|
||||||
|
|
||||||
|
| Crate | Used by | Purpose |
|
||||||
|
|-------------|---------------------|-------------------------------|
|
||||||
|
| `yubikey` | notnet, notsecrets | PIV slot access (pure Rust) |
|
||||||
|
| `tailscale` | notnet | tailscale-rs SDK |
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Multi-YubiKey support (first connected device wins)
|
||||||
|
- Tailscale exit node or subnet router configuration
|
||||||
|
- Rotating auth keys automatically
|
||||||
|
- Windows support
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user