8 Commits

Author SHA1 Message Date
Joseph OBrien
df7df3d65c feat(cli): default dotfiles dir to ~/.notfiles, config to ~/.config/notfiles/notfiles.toml
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
- Add `default_dotfiles_dir()` (~/.notfiles) and `default_config_path()`
  (~/.config/notfiles/notfiles.toml) to notcore::config
- Add `Config::load_from(path)` for explicit config path; keep `Config::load(dir)`
  as the legacy/library variant (config inside dotfiles dir)
- CLI resolves config via XDG default or --config override; library functions
  stay legacy so the public API is unaffected
- `notfiles init` creates ~/.notfiles and ~/.config/notfiles/notfiles.toml
- Add --config global flag to override the config path
- Update integration tests to pass --config explicitly so they are isolated
  from any real ~/.config/notfiles/notfiles.toml on the test machine
2026-07-14 10:42:35 -04:00
d34a4f9363 chore(repo): add git-cliff config, sort deps, add rust-version
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
- Add cliff.toml with GitHub remote wiring, issue link parsers, and
  clean group names (no sort prefixes)
- Add rust-version = "1.87.0" and codegen-units = 1 to workspace
- Sort workspace.dependencies alphabetically; add notstrap path entry
- Add CLAUDE.md preflight block; reformat workspace table to 100-col
- Delete stale brainstorm artifacts and root HANDOFF.md
2026-07-14 10:14:43 -04:00
d9f476ff79 chore(repo): remove nested agent worktree marker
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
2026-07-11 23:53:42 -04:00
Joseph O'Brien
4b4b1df5db Merge pull request #20 from 89jobrien/chore/sort-cargo-manifests
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
chore(manifests): sort Cargo.toml dependencies
2026-07-11 23:49:24 -04:00
0cfa0d9b3c fix(ci): install nushell for integration tests 2026-07-11 23:44:04 -04:00
1a3aaf2dd9 feat(notforge): add Gitea API foundation 2026-07-11 23:33:24 -04:00
3a475bc25b chore(repo): stop tracking macOS artifacts 2026-07-11 23:33:04 -04:00
Joseph O'Brien
4b344621bc docs: note absorption of pj into notfiles workspace 2026-04-14 08:54:51 -04:00
41 changed files with 1677 additions and 172 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored

Binary file not shown.

Submodule .claude/worktrees/agent-a27fe5f2 deleted from 0d60583c65

View File

@@ -1,4 +1,4 @@
# Handoff — notfiles (2026-07-07) # Handoff — notfiles (2026-07-14)
**Branch:** main | **Build:** unknown | **Tests:** unknown **Branch:** main | **Build:** unknown | **Tests:** unknown
@@ -11,6 +11,7 @@
## Log ## Log
- 20260606.135918: done=13 running=0 pending=0 blocked=0 - 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-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: 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 - 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work

View File

@@ -1,6 +1,6 @@
project: notfiles project: notfiles
id: notfile id: notfile
updated: 2026-07-08 updated: 2026-07-14
items: items:
- id: uncommitted-work - id: uncommitted-work
doob_uuid: null doob_uuid: null
@@ -20,14 +20,12 @@ items:
completed: null completed: null
extra: [] extra: []
log: log:
- date: 2026-07-08
summary: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo
commits:
- 6427d18
- 25624c9
- date: '20260606.135918' - date: '20260606.135918'
summary: done=13 running=0 pending=0 blocked=0 summary: done=13 running=0 pending=0 blocked=0
commits: [] 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 - date: 2026-04-17
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
commits: [] commits: []

View File

@@ -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

5
.gitignore vendored
View File

@@ -1,6 +1,11 @@
/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/ !docs/superpowers/

View File

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

View File

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

View File

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

View File

@@ -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?
@@ -24,19 +30,19 @@ cargo fmt --check # check formatting
This is a Cargo workspace with 7 crates under `crates/`: This is a Cargo workspace with 7 crates under `crates/`:
| Crate | Purpose | | Crate | Purpose |
| ------------ | -------------------------------------------------------------------------- | | ------------ | ----------------------------------------------------------------------- |
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, | | `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` | | | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, | | `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` | | | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ | | `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
| | decryption, identity management | | | decryption, identity management |
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence | | `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
| `notnet` | Network utilities — Tailscale integration, YubikeySource | | `notnet` | Network utilities — Tailscale integration, YubikeySource |
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together | | `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, | | `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
| | heatmap, cycle detection | | | heatmap, cycle detection |
## Architecture ## Architecture

450
Cargo.lock generated
View File

@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aead" name = "aead"
version = "0.5.2" version = "0.5.2"
@@ -332,6 +338,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -479,6 +494,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "doc-comment" name = "doc-comment"
version = "0.3.4" version = "0.3.4"
@@ -606,12 +632,31 @@ version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -745,12 +790,115 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]] [[package]]
name = "id-arena" name = "id-arena"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "ignore" name = "ignore"
version = "0.4.25" version = "0.4.25"
@@ -866,6 +1014,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@@ -878,12 +1032,44 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"unicode-width",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -898,7 +1084,6 @@ dependencies = [
name = "notcore" name = "notcore"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"dirs", "dirs",
"serde", "serde",
"strsim", "strsim",
@@ -924,6 +1109,21 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "notforge"
version = "0.1.0"
dependencies = [
"base64",
"miette",
"notcore",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"toml",
"ureq",
]
[[package]] [[package]]
name = "notgraph" name = "notgraph"
version = "0.1.0" version = "0.1.0"
@@ -970,7 +1170,6 @@ dependencies = [
"bech32", "bech32",
"chacha20poly1305", "chacha20poly1305",
"curve25519-dalek", "curve25519-dalek",
"dirs",
"ed25519-dalek", "ed25519-dalek",
"hkdf", "hkdf",
"hmac", "hmac",
@@ -985,7 +1184,6 @@ dependencies = [
"toml", "toml",
"x25519-dalek", "x25519-dalek",
"yubikey", "yubikey",
"zeroize",
] ]
[[package]] [[package]]
@@ -1148,6 +1346,12 @@ dependencies = [
"base64ct", "base64ct",
] ]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pkcs1" name = "pkcs1"
version = "0.7.5" version = "0.7.5"
@@ -1186,6 +1390,15 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1333,6 +1546,20 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.4.0" version = "7.4.0"
@@ -1397,6 +1624,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -1556,6 +1818,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@@ -1578,6 +1846,12 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -1601,6 +1875,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -1660,6 +1945,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tls_codec" name = "tls_codec"
version = "0.4.2" version = "0.4.2"
@@ -1734,6 +2029,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@@ -1750,6 +2051,48 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -1886,6 +2229,24 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "which" name = "which"
version = "7.0.3" version = "7.0.3"
@@ -2160,6 +2521,12 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x25519-dalek" name = "x25519-dalek"
version = "2.0.1" version = "2.0.1"
@@ -2186,6 +2553,29 @@ dependencies = [
"tls_codec", "tls_codec",
] ]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "yubikey" name = "yubikey"
version = "0.8.0" version = "0.8.0"
@@ -2239,6 +2629,27 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.8.2"
@@ -2259,6 +2670,39 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

View File

@@ -5,6 +5,7 @@ homepage = "https://github.com/89jobrien/notfiles"
keywords = ["dotfiles", "symlink", "stow", "config"] keywords = ["dotfiles", "symlink", "stow", "config"]
categories = ["command-line-utilities", "filesystem"] categories = ["command-line-utilities", "filesystem"]
readme = "README.md" readme = "README.md"
rust-version = "1.87.0"
[workspace] [workspace]
members = [ members = [
@@ -15,33 +16,40 @@ members = [
"crates/notnet", "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"
assert_fs = "1"
base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "6" dirs = "6"
globset = "0.4" globset = "0.4"
miette = { version = "7", features = ["derive"] }
notcore = { path = "crates/notcore" }
notfiles = { path = "crates/notfiles" }
notforge = { path = "crates/notforge" }
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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
strsim = "0.11" strsim = "0.11"
tempfile = "3"
thiserror = "2" thiserror = "2"
toml = "0.8" toml = "0.8"
ureq = { version = "2", features = ["json"] }
which = "7" which = "7"
rpassword = "7"
tempfile = "3"
assert_fs = "1"
notcore = { path = "crates/notcore" }
notfiles = { path = "crates/notfiles" }
notsecrets = { path = "crates/notsecrets" }
nothooks = { path = "crates/nothooks" }
notnet = { path = "crates/notnet" }
[profile.release] [profile.release]
codegen-units = 1
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
strip = true strip = true

View File

@@ -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

View File

@@ -265,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
View 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" },
]

BIN
crates/.DS_Store vendored

Binary file not shown.

View File

@@ -10,7 +10,6 @@ categories.workspace = true
description = "Shared types and config for the notfiles dotfiles manager" description = "Shared types and config for the notfiles dotfiles manager"
[dependencies] [dependencies]
anyhow = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
strsim = { workspace = true } strsim = { workspace = true }

View File

@@ -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;
@@ -85,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| {
@@ -100,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)
@@ -204,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();
@@ -323,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");
}
} }

Binary file not shown.

View File

@@ -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,

View File

@@ -15,10 +15,26 @@ 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 { let reporter: Box<dyn Reporter> = if cli.json {
Box::new(JsonReporter) Box::new(JsonReporter)
} else { } else {
@@ -37,14 +53,14 @@ fn main() -> Result<()> {
detect::print_detected(&managers); detect::print_detected(&managers);
} }
} }
Command::Init => cmd_init(&dotfiles_dir)?, 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()?; config.validate()?;
let mut state = State::load(&dotfiles_dir, fs)?; let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -89,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() {
@@ -145,7 +161,7 @@ fn main() -> Result<()> {
} }
Command::Check => { Command::Check => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?; let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?;
@@ -171,7 +187,7 @@ fn main() -> Result<()> {
} }
Command::Diff { packages } => { Command::Diff { packages } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -183,7 +199,7 @@ fn main() -> Result<()> {
} }
Command::Adopt { package, files } => { Command::Adopt { package, files } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
let mut state = State::load(&dotfiles_dir, fs)?; let mut state = State::load(&dotfiles_dir, fs)?;
let opts = LinkOptions { let opts = LinkOptions {
force: false, force: false,
@@ -217,7 +233,7 @@ fn main() -> Result<()> {
} }
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()?; config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -236,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(())
} }

View File

@@ -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());
} }

View 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 }

View 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())),
)
}

View 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),
}

View 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 })
}

View 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"));
}
}

View File

@@ -0,0 +1,3 @@
fn main() {
println!("{}", notforge::VERSION);
}

View 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>;
}

View 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"
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -10,7 +10,6 @@ base64 = "0.22"
bech32 = "0.11" bech32 = "0.11"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
curve25519-dalek = "4" curve25519-dalek = "4"
dirs = { workspace = true }
ed25519-dalek = "2" ed25519-dalek = "2"
hkdf = "0.12" hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
@@ -24,7 +23,6 @@ thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
x25519-dalek = { version = "2", features = ["static_secrets"] } x25519-dalek = { version = "2", features = ["static_secrets"] }
yubikey = { version = "0.8", optional = true } yubikey = { version = "0.8", optional = true }
zeroize = "1"
[features] [features]
default = [] default = []

View File

@@ -79,9 +79,11 @@ pub struct SecretsConfig {
} }
pub fn load_config(path: &Path) -> Result<SecretsConfig, SecretsError> { pub fn load_config(path: &Path) -> Result<SecretsConfig, SecretsError> {
let content = std::fs::read_to_string(path) notcore::config::load_toml_file(
.map_err(|e| SecretsError::Config(format!("cannot read {}: {e}", path.display())))?; path,
toml::from_str(&content).map_err(|e| SecretsError::Config(format!("parse error: {e}"))) |path, err| SecretsError::Config(format!("cannot read {}: {err}", path.display())),
|_path, err| SecretsError::Config(format!("parse error: {err}")),
)
} }
#[cfg(test)] #[cfg(test)]

Binary file not shown.

View File

@@ -3,7 +3,7 @@
A `rust-script` preflight that surfaces repo context in one shot: shell detection, git state, A `rust-script` preflight that surfaces repo context in one shot: shell detection, git state,
tracked-file tree, and HANDOFF document read/update. tracked-file tree, and HANDOFF document read/update.
```rust ````rust
#!/usr/bin/env rust-script #!/usr/bin/env rust-script
//! ```cargo //! ```cargo
//! [dependencies] //! [dependencies]
@@ -238,7 +238,7 @@ fn main() -> Result<()> {
println!("\n{}", "╚══ done ══╝".bold().yellow()); println!("\n{}", "╚══ done ══╝".bold().yellow());
Ok(()) Ok(())
} }
``` ````
## Usage ## Usage
@@ -253,13 +253,13 @@ rust-script preflight.rs
## What it surfaces ## What it surfaces
| Section | Details | | Section | Details |
|---------|---------| | ----------------- | ------------------------------------------------------------------------------------------------------ |
| Environment | Current shell (`$SHELL`), cwd, git root | | Environment | Current shell (`$SHELL`), cwd, git root |
| Git status | Branch name, short status (clean or dirty file list) | | Git status | Branch name, short status (clean or dirty file list) |
| Git history | Last 7 commits, one-line with decoration | | Git history | Last 7 commits, one-line with decoration |
| Tracked file tree | `git ls-files` output rendered as an indented tree, depth ≤ 3 | | Tracked file tree | `git ls-files` output rendered as an indented tree, depth ≤ 3 |
| HANDOFF docs | Full content (first 40 lines) of any `HANDOFF.*` file at repo root; stamps `last_preflight:` timestamp | | HANDOFF docs | Full content (first 40 lines) of any `HANDOFF.*` file at repo root; stamps `last_preflight:` timestamp |
## Dependencies ## Dependencies

View File

@@ -3,7 +3,7 @@ name = "integration"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license.workspace = true license.workspace = true
publish = false publish = true
[[test]] [[test]]
name = "bootstrap" name = "bootstrap"
@@ -16,9 +16,9 @@ path = "tests/cross_crate.rs"
[dev-dependencies] [dev-dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
assert_fs = { workspace = true } assert_fs = { workspace = true }
notcore = { path = "../../crates/notcore" } notcore = { workspace = true } #unified
notfiles = { path = "../../crates/notfiles" } notfiles = { workspace = true } #unified
nothooks = { path = "../../crates/nothooks" } nothooks = { workspace = true } #unified
notsecrets = { path = "../../crates/notsecrets" } notsecrets = { workspace = true } #unified
notstrap = { path = "../../crates/notstrap" } notstrap = { workspace = true } #unified
tempfile = { workspace = true } tempfile = { workspace = true }