docs: update HANDOFF.md — age-native redesign progress (tasks 1–7 complete)

This commit is contained in:
Joseph O'Brien
2026-04-01 20:58:09 -04:00
parent 545a45b3d3
commit b4cba74ea3
24 changed files with 1069 additions and 99 deletions

562
Cargo.lock generated
View File

@@ -2,6 +2,16 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -97,12 +107,39 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -135,6 +172,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -146,6 +207,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.0"
@@ -192,12 +264,27 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -223,12 +310,73 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]] [[package]]
name = "difflib" name = "difflib"
version = "0.4.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@@ -256,6 +404,30 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -290,6 +462,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -302,6 +480,16 @@ 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 = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -371,6 +559,24 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@@ -429,6 +635,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "integration" name = "integration"
version = "0.1.0" version = "0.1.0"
@@ -465,6 +680,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -477,6 +701,12 @@ version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.15" version = "0.1.15"
@@ -548,12 +778,24 @@ name = "notsecrets"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"bech32",
"chacha20poly1305",
"curve25519-dalek",
"dirs", "dirs",
"ed25519-dalek",
"hkdf",
"hmac",
"notcore", "notcore",
"rand",
"rpassword", "rpassword",
"rsa",
"scrypt",
"sha2",
"tempfile", "tempfile",
"thiserror", "thiserror",
"which", "x25519-dalek",
"zeroize",
] ]
[[package]] [[package]]
@@ -571,6 +813,42 @@ dependencies = [
"which", "which",
] ]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -578,6 +856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -592,12 +871,89 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "predicates" name = "predicates"
version = "3.1.4" version = "3.1.4"
@@ -659,6 +1015,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -698,6 +1084,27 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"sha2",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rtoolbox" name = "rtoolbox"
version = "0.0.3" version = "0.0.3"
@@ -708,6 +1115,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -727,6 +1143,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -736,6 +1161,18 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -794,18 +1231,67 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -897,6 +1383,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -909,12 +1401,28 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -1302,6 +1810,58 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core",
"serde",
"zeroize",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

View File

@@ -1,9 +1,11 @@
# HANDOFF.md # HANDOFF.md
State of the `notfiles` repo as of 2026-03-31. State of the `notfiles` repo as of 2026-04-01.
## What Was Done ## What Was Done
### Previous session (2026-03-31)
Restructured from a flat `src/` layout into a Cargo workspace of 5 crates: Restructured from a flat `src/` layout into a Cargo workspace of 5 crates:
- `notcore` — shared library (types, config, paths, errors) - `notcore` — shared library (types, config, paths, errors)
- `notfiles` — dotfiles linker, migrated from `src/` - `notfiles` — dotfiles linker, migrated from `src/`
@@ -11,27 +13,61 @@ Restructured from a flat `src/` layout into a Cargo workspace of 5 crates:
- `nothooks` — Nushell hook runner with phase/state tracking - `nothooks` — Nushell hook runner with phase/state tracking
- `notstrap` — new-machine bootstrap orchestrator - `notstrap` — new-machine bootstrap orchestrator
39 tests passing, 0 clippy warnings. Committed to `main`, not yet pushed to `gitea/main`. ### This session (2026-04-01)
## Pending Issues Began age-native redesign of `notsecrets` — replacing sops shell-out and Bitwarden CLI dependency with a self-contained age encryption/decryption library implemented in pure Rust (modeled after rage, no dependency on it).
**Spec and plan written:**
- `docs/superpowers/specs/2026-04-01-notsecrets-age-redesign.md`
- `docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md`
**Implementation progress (Tasks 17 of 12 complete):**
| Task | Status | Commit |
|------|--------|--------|
| 1: Scaffold (Cargo.toml, error.rs, domain types, legacy shim) | ✅ | `34d9e3d` |
| 2: format.rs (age wire format parser/serializer) | ✅ | `2476daa` |
| 3: X25519 identity + recipient | ✅ | `6202c5d` |
| 4: Scrypt identity + recipient | ✅ | `b899198` |
| 5: SSH Ed25519 identity + recipient | ✅ | `566501c` |
| 6: SSH RSA identity + recipient | ✅ | `a0fe6f7` |
| 7: EncryptedIdentity (stub, real impl needs Decryptor) | ✅ | `545a45b` |
| 8: Encryptor | ⏳ not started | |
| 9: Decryptor + wire EncryptedIdentity | ⏳ not started | |
| 10: sources/ migration (IdentitySource trait) | ⏳ not started | |
| 11: resolve_identities() public API | ⏳ not started | |
| 12: notstrap migration + cargo check --workspace | ⏳ not started | |
**Current state:**
- `notsecrets` compiles and passes all existing tests
- Legacy API (`AgeKeySource`, `resolve_age_key`, `install_age_key`, `decrypt_sops`) preserved in `_legacy.rs``notstrap` still works
- `EncryptedIdentity::unwrap_file_key` is a stub returning an error until `Decryptor` is wired in Task 9
## Resuming
To continue the age-native redesign, pick up at Task 8 (Encryptor). The plan is at:
`docs/superpowers/plans/2026-04-01-notsecrets-age-redesign.md`
Tasks 812 need to be executed in order (each depends on the previous). Use the subagent-driven-development skill to dispatch implementer subagents per task.
Key context for resuming:
- `EncryptedIdentity` stub in `src/identities/encrypted.rs` must have its `unwrap_file_key` body replaced after `Decryptor` exists (end of Task 9)
- The `EnvInjector` type in `notstrap` changes from `Box<dyn Fn>` to `Box<dyn FnOnce>` in Task 12
- `_legacy.rs` is deleted in Task 10
## Pending Issues (carried from previous session)
### 1. Push to remote ### 1. Push to remote
```bash Several commits ahead of `gitea/main`. Push when ready.
git push
```
8 commits ahead of `gitea/main`. Nothing blocking this.
### 2. notstrap has an empty lib.rs ### 2. notstrap has an empty lib.rs
`crates/notstrap/src/lib.rs` is empty — created as a stub. `notstrap` is binary-only so this is harmless, but it's dead weight. Either delete `[lib]` from `notstrap/Cargo.toml` (if there is one) or remove the file. `crates/notstrap/src/lib.rs` is empty — created as a stub. Either delete `[lib]` from `notstrap/Cargo.toml` or remove the file.
### 3. notstrap config not documented ### 3. notstrap config not documented
`notstrap.toml` format is defined inline in `notstrap/src/main.rs` via serde structs but never written down. A sample `notstrap.toml` should be created and documented. `notstrap.toml` format is defined inline via serde structs but never written down. A sample config would help.
### 4. notsecrets has no binary ### 4. Integration test coverage for nothooks assumes nu in PATH
`notsecrets` is library-only. A small `notsecrets get-key` CLI binary could be useful for manual debugging/testing of the key retrieval chain, but not blocking. `nothooks` integration tests call `nu` directly. CI needs `nu` installed or tests need `#[ignore]` + feature flag.
### 5. Integration test coverage for nothooks assumes nu in PATH ### 5. notstrap sops_file path footgun
`nothooks` integration tests call `nu` directly. If `nu` is not on PATH (e.g. on CI), the success/failure tests will fail for the wrong reason. CI should either install `nu` or the tests should be marked `#[ignore]` with a feature flag. If `sops_file` in `notstrap.toml` is an absolute path, the `join` will silently override the base. Low priority.
### 6. notstrap sops_file path is always joined to dotfiles_dir
If `sops_file` in `notstrap.toml` is an absolute path, the join will silently override the base. This is a minor footgun in `repo.clone_if_missing` usage — low priority.

View File

@@ -77,10 +77,12 @@ impl Config {
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) let content = std::fs::read_to_string(&config_path).map_err(|e| {
.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| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?; let config: Config = toml::from_str(&content).map_err(|e| {
NotfilesError::Config(format!("parsing {}: {e}", config_path.display()))
})?;
Ok(config) Ok(config)
} }

View File

@@ -48,7 +48,10 @@ pub struct Report {
impl Report { impl Report {
pub fn add(&mut self, name: impl Into<String>, status: StepStatus) { pub fn add(&mut self, name: impl Into<String>, status: StepStatus) {
self.steps.push(Step { name: name.into(), status }); self.steps.push(Step {
name: name.into(),
status,
});
} }
pub fn print(&self) { pub fn print(&self) {
@@ -67,7 +70,9 @@ impl Report {
} }
pub fn has_failures(&self) -> bool { pub fn has_failures(&self) -> bool {
self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_))) self.steps
.iter()
.any(|s| matches!(s.status, StepStatus::Failed(_)))
} }
} }

View File

@@ -14,7 +14,9 @@ impl IgnoreMatcher {
// Match the pattern as a filename component and also as a path suffix. // Match the pattern as a filename component and also as a path suffix.
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| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?; .map_err(|e| {
NotfilesError::Other(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.
#[allow(clippy::collapsible_if)] #[allow(clippy::collapsible_if)]

View File

@@ -10,11 +10,7 @@ use std::path::Path;
pub use linker::{LinkOptions, State}; pub use linker::{LinkOptions, State};
pub use package::resolve_packages; pub use package::resolve_packages;
pub fn link( pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State> {
let config = notcore::Config::load(dotfiles_dir)?; let config = notcore::Config::load(dotfiles_dir)?;
let mut state = State::load(dotfiles_dir)?; let mut state = State::load(dotfiles_dir)?;
let pkgs = resolve_packages(dotfiles_dir, packages)?; let pkgs = resolve_packages(dotfiles_dir, packages)?;

View File

@@ -4,8 +4,8 @@ use std::path::{Path, PathBuf};
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use notcore::{Config, Method, NotfilesError, expand_tilde};
use crate::package::collect_files; use crate::package::collect_files;
use notcore::{Config, Method, NotfilesError, expand_tilde};
const STATE_FILE: &str = ".notfiles-state.toml"; const STATE_FILE: &str = ".notfiles-state.toml";
@@ -46,7 +46,10 @@ impl State {
} }
pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> { pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> {
self.entries.iter().filter(|e| e.package == package).collect() self.entries
.iter()
.filter(|e| e.package == package)
.collect()
} }
pub fn remove_package(&mut self, package: &str) { pub fn remove_package(&mut self, package: &str) {
@@ -55,7 +58,8 @@ impl State {
pub fn add_entry(&mut self, entry: StateEntry) { pub fn add_entry(&mut self, entry: StateEntry) {
// Remove existing entry for same source+target, then add new // Remove existing entry for same source+target, then add new
self.entries.retain(|e| !(e.source == entry.source && e.target == entry.target)); self.entries
.retain(|e| !(e.source == entry.source && e.target == entry.target));
self.entries.push(entry); self.entries.push(entry);
} }
} }
@@ -113,10 +117,18 @@ 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!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display()); println!(
" \x1b[33mwould backup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
} else { } else {
if opts.verbose { if opts.verbose {
println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display()); println!(
" \x1b[33mbackup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
} }
fs::rename(&target, &backup)?; fs::rename(&target, &backup)?;
} }
@@ -147,7 +159,10 @@ pub fn link_package(
}; };
if opts.dry_run { if opts.dry_run {
println!(" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", target.display()); println!(
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
target.display()
);
} else { } else {
match method { match method {
Method::Symlink => { Method::Symlink => {
@@ -161,7 +176,10 @@ pub fn link_package(
} }
} }
if opts.verbose { if opts.verbose {
println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display()); println!(
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
target.display()
);
} }
state.add_entry(StateEntry { state.add_entry(StateEntry {
@@ -183,7 +201,11 @@ pub fn unlink_package(
package: &str, package: &str,
opts: &LinkOptions, opts: &LinkOptions,
) -> Result<(), NotfilesError> { ) -> Result<(), NotfilesError> {
let entries: Vec<StateEntry> = state.entries_for_package(package).into_iter().cloned().collect(); let entries: Vec<StateEntry> = state
.entries_for_package(package)
.into_iter()
.cloned()
.collect();
if entries.is_empty() { if entries.is_empty() {
if opts.verbose { if opts.verbose {
@@ -278,7 +300,10 @@ fn cleanup_empty_parents(path: &Path) {
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") { if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
break; break;
} }
if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) { if fs::read_dir(parent)
.map(|mut d| d.next().is_none())
.unwrap_or(false)
{
let _ = fs::remove_dir(parent); let _ = fs::remove_dir(parent);
dir = parent.parent(); dir = parent.parent();
} else { } else {

View File

@@ -2,11 +2,11 @@ use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use std::fs; use std::fs;
use notcore::Config;
use notfiles::cli::{Cli, Command}; use notfiles::cli::{Cli, Command};
use notfiles::linker::{LinkOptions, State}; use notfiles::linker::{LinkOptions, State};
use notfiles::package::resolve_packages; use notfiles::package::resolve_packages;
use notfiles::{linker, status}; use notfiles::{linker, status};
use notcore::Config;
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -46,7 +46,10 @@ fn main() -> Result<()> {
if !cli.dry_run { if !cli.dry_run {
state.save(&dotfiles_dir)?; state.save(&dotfiles_dir)?;
let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum(); let count: usize = pkgs
.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" },

View File

@@ -1,8 +1,8 @@
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use notcore::{Config, NotfilesError};
use crate::ignore::IgnoreMatcher; use crate::ignore::IgnoreMatcher;
use notcore::{Config, NotfilesError};
/// 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> {

View File

@@ -1,9 +1,9 @@
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use notcore::{Config, Method, expand_tilde};
use crate::linker::State; use crate::linker::State;
use crate::package::collect_files; use crate::package::collect_files;
use notcore::{Config, Method, expand_tilde};
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum FileStatus { pub enum FileStatus {
@@ -69,8 +69,7 @@ pub fn package_status(
} }
Method::Copy => { Method::Copy => {
let has_state = state.entries.iter().any(|e| { let has_state = state.entries.iter().any(|e| {
e.package == package e.package == package && e.target == target.to_string_lossy().as_ref()
&& e.target == target.to_string_lossy().as_ref()
}); });
if has_state && target.exists() { if has_state && target.exists() {
FileStatus::Copied FileStatus::Copied
@@ -122,6 +121,11 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
} }
println!(" \x1b[1m{package}\x1b[0m:"); println!(" \x1b[1m{package}\x1b[0m:");
for entry in entries { for entry in entries {
println!(" {} {} -> {}", entry.status, entry.source_display, entry.target.display()); println!(
" {} {} -> {}",
entry.status,
entry.source_display,
entry.target.display()
);
} }
} }

View File

@@ -94,7 +94,13 @@ fn test_link_and_status() {
let gitconfig = target.join(".gitconfig"); let gitconfig = target.join(".gitconfig");
assert!(gitconfig.exists()); assert!(gitconfig.exists());
assert!(gitconfig.symlink_metadata().unwrap().file_type().is_symlink()); assert!(
gitconfig
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
// State file should exist // State file should exist
assert!(dotfiles.join(".notfiles-state.toml").exists()); assert!(dotfiles.join(".notfiles-state.toml").exists());
@@ -199,7 +205,14 @@ fn test_force_with_backup() {
assert!(ok); assert!(ok);
// The link should now exist // The link should now exist
assert!(target.join(".gitconfig").symlink_metadata().unwrap().file_type().is_symlink()); assert!(
target
.join(".gitconfig")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
// A backup should exist // A backup should exist
let backups: Vec<_> = fs::read_dir(&target) let backups: Vec<_> = fs::read_dir(&target)
@@ -232,11 +245,7 @@ fn test_force_no_backup() {
let backups: Vec<_> = fs::read_dir(&target) let backups: Vec<_> = fs::read_dir(&target)
.unwrap() .unwrap()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| e.file_name().to_string_lossy().contains("notfiles-backup"))
e.file_name()
.to_string_lossy()
.contains("notfiles-backup")
})
.collect(); .collect();
assert_eq!(backups.len(), 0); assert_eq!(backups.len(), 0);
} }
@@ -272,8 +281,17 @@ method = "copy"
let ssh_config = target.join(".ssh/config"); let ssh_config = target.join(".ssh/config");
assert!(ssh_config.exists()); assert!(ssh_config.exists());
// Should NOT be a symlink // Should NOT be a symlink
assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink()); assert!(
assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes"); !ssh_config
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(
fs::read_to_string(&ssh_config).unwrap(),
"Host *\n AddKeysToAgent yes"
);
// Status should show "copied" // Status should show "copied"
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]); let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);

121
crates/nothooks/README.md Normal file
View File

@@ -0,0 +1,121 @@
# nothooks
Nushell hook runner for the notfiles workspace. Executes `.nu` scripts in two
distinct lifecycle phases — **dot** (always) and **setup** (once) — with
state persistence so setup hooks are not re-run across invocations.
## Concepts
### HookPhase
Each hook belongs to exactly one phase:
| Phase | When it runs | Typical use |
|-------|-------------|-------------|
| `dot` | Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks |
| `setup` | Once per machine (tracked in state) | Install packages, create dirs, first-time configuration |
### HookSpec
A hook is described by three fields (defined in `notcore`):
```toml
[[hooks]]
name = "install-starship"
script = "hooks/setup/install-starship.nu"
phase = "setup"
[[hooks]]
name = "set-default-shell"
script = "hooks/dot/shell.nu"
phase = "dot"
```
### State tracking
Setup hooks are tracked in `.nothooks-state.toml` in the state directory
(typically the dotfiles root). Once a setup hook completes successfully its
name is recorded and it will be **skipped** on all future runs.
```toml
# .nothooks-state.toml (auto-generated, do not edit manually)
completed_setup_hooks = ["install-starship", "configure-git"]
```
Dot hooks are **never** recorded in state — they run unconditionally on every
invocation.
### --force re-run
To re-run setup hooks (e.g. after a machine rebuild or to apply changes),
construct the runner with `HookRunner::with_force`. This bypasses the state
check so all setup hooks execute regardless of prior completion.
The notstrap CLI exposes this as `--force`.
## Hook script lifecycle
```
notstrap / nothooks invoked
├── dot phase
│ └── for each hook where phase == "dot"
│ └── nu <script> # always runs
└── setup phase
└── for each hook where phase == "setup"
├── state.is_done(name)?
│ ├── yes (and !force) → skip
│ └── no (or force) → nu <script>
│ └── success → state.mark_done(name)
└── report collected
```
## Writing a hook script
Hook scripts are plain Nushell (`.nu`) files. They receive no arguments and
communicate success/failure via exit code (0 = success, non-zero = failure).
```nushell
#!/usr/bin/env nu
# hooks/setup/install-starship.nu
# Installs starship prompt if not already present.
if (which starship | is-empty) {
print "Installing starship..."
sh -c "curl -sS https://starship.rs/install.sh | sh -s -- -y"
} else {
print "starship already installed, skipping"
}
```
```nushell
#!/usr/bin/env nu
# hooks/dot/xdg-dirs.nu
# Ensure XDG base directories exist on every login.
mkdir ~/.config ~/.cache ~/.local/share ~/.local/state
```
## Usage from Rust
```rust
use nothooks::{HookRunner, run_phase};
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
let hooks: Vec<HookSpec> = /* load from notfiles.toml */;
let state_dir = PathBuf::from("/path/to/dotfiles");
// Normal run
let runner = HookRunner::new(state_dir.clone());
// Force re-run of setup hooks
let runner = HookRunner::with_force(state_dir.clone());
let dot_report = run_phase(&hooks, &HookPhase::Dot, &runner);
let setup_report = run_phase(&hooks, &HookPhase::Setup, &runner);
```
`run_phase` returns a `notcore::Report` containing a `StepStatus` (`Ok`,
`Skipped`, or `Failed(msg)`) for each hook in that phase.

View File

@@ -1,8 +1,8 @@
pub mod runner; pub mod runner;
pub mod state; pub mod state;
pub use runner::HookRunner;
use notcore::{HookPhase, HookSpec, Report, StepStatus}; use notcore::{HookPhase, HookSpec, Report, StepStatus};
pub use runner::HookRunner;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum HookResult { pub enum HookResult {
@@ -12,11 +12,7 @@ pub enum HookResult {
} }
/// Run all hooks matching `phase` and collect into a `Report`. /// Run all hooks matching `phase` and collect into a `Report`.
pub fn run_phase( pub fn run_phase(hooks: &[HookSpec], phase: &HookPhase, runner: &HookRunner) -> Report {
hooks: &[HookSpec],
phase: &HookPhase,
runner: &HookRunner,
) -> Report {
let mut report = Report::default(); let mut report = Report::default();
for hook in hooks.iter().filter(|h| &h.phase == phase) { for hook in hooks.iter().filter(|h| &h.phase == phase) {
let result = runner.run_hook(hook); let result = runner.run_hook(hook);

View File

@@ -1,7 +1,7 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use nothooks::{run_phase, HookRunner};
use notcore::{HookPhase, HookSpec}; use notcore::{HookPhase, HookSpec};
use nothooks::{HookRunner, run_phase};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]

View File

@@ -1,8 +1,8 @@
use std::path::PathBuf;
use std::process::Command;
use notcore::{HookPhase, HookSpec};
use crate::HookResult; use crate::HookResult;
use crate::state::HookState; use crate::state::HookState;
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
use std::process::Command;
pub struct HookRunner { pub struct HookRunner {
state_dir: PathBuf, state_dir: PathBuf,
@@ -11,11 +11,17 @@ pub struct HookRunner {
impl HookRunner { impl HookRunner {
pub fn new(state_dir: PathBuf) -> Self { pub fn new(state_dir: PathBuf) -> Self {
Self { state_dir, force: false } Self {
state_dir,
force: false,
}
} }
pub fn with_force(state_dir: PathBuf) -> Self { pub fn with_force(state_dir: PathBuf) -> Self {
Self { state_dir, force: true } Self {
state_dir,
force: true,
}
} }
pub fn run_hook(&self, spec: &HookSpec) -> HookResult { pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
@@ -25,9 +31,8 @@ impl HookRunner {
return HookResult::Skipped; return HookResult::Skipped;
} }
let result = Command::new("nu") // TODO: support other shells
.arg(&spec.script) let result = Command::new("nu").arg(&spec.script).status();
.status();
match result { match result {
Ok(status) if status.success() => { Ok(status) if status.success() => {

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::path::Path;
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
const STATE_FILE: &str = ".nothooks-state.toml"; const STATE_FILE: &str = ".nothooks-state.toml";

View File

@@ -1,7 +1,7 @@
use notcore::{HookPhase, HookSpec};
use nothooks::{HookResult, HookRunner};
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
use nothooks::{HookResult, HookRunner};
use notcore::{HookPhase, HookSpec};
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec { fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
let path = dir.path().join(format!("{name}.nu")); let path = dir.path().join(format!("{name}.nu"));

View File

@@ -1,8 +1,8 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus}; use notcore::{HookPhase, Report, StepStatus};
use notfiles::{link, LinkOptions}; use notfiles::{LinkOptions, link};
use nothooks::{run_phase, HookRunner}; use nothooks::{HookRunner, run_phase};
use notsecrets::{install_age_key, resolve_age_key, BitwardenSource, FileSource, PromptSource}; use notsecrets::{BitwardenSource, FileSource, PromptSource, install_age_key, resolve_age_key};
use serde::Deserialize; use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -28,8 +28,12 @@ pub struct BootstrapSection {
pub sops_file: String, pub sops_file: String,
} }
pub(crate) fn default_bw_item() -> String { "age-key-dotfiles".to_string() } pub(crate) fn default_bw_item() -> String {
pub(crate) fn default_sops_file() -> String { "secrets/bootstrap.sops.env".to_string() } "age-key-dotfiles".to_string()
}
pub(crate) fn default_sops_file() -> String {
"secrets/bootstrap.sops.env".to_string()
}
pub struct BootstrapOptions { pub struct BootstrapOptions {
pub config: PathBuf, pub config: PathBuf,
@@ -48,7 +52,9 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
// 1. Prerequisites // 1. Prerequisites
if let Some(check) = opts.check_prereqs { if let Some(check) = opts.check_prereqs {
match check() { match check() {
Ok(_) => { report.add("prerequisites", StepStatus::Ok); } Ok(_) => {
report.add("prerequisites", StepStatus::Ok);
}
Err(e) => { Err(e) => {
report.add("prerequisites", StepStatus::Failed(e.to_string())); report.add("prerequisites", StepStatus::Failed(e.to_string()));
return Ok(report); return Ok(report);
@@ -63,15 +69,20 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
let dotfiles_dir = match opts.dotfiles { let dotfiles_dir = match opts.dotfiles {
Some(d) => d, Some(d) => d,
None => notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir) None => notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir).with_context(|| {
.with_context(|| format!("cannot expand dotfiles_dir: {}", cfg.bootstrap.dotfiles_dir))?, format!("cannot expand dotfiles_dir: {}", cfg.bootstrap.dotfiles_dir)
})?,
}; };
// 3. Clone dotfiles if missing // 3. Clone dotfiles if missing
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) { match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
Ok(true) => { report.add("clone dotfiles", StepStatus::Ok); } Ok(true) => {
Ok(false) => { report.add("clone dotfiles", StepStatus::Skipped); } report.add("clone dotfiles", StepStatus::Ok);
Err(e) => { }
Ok(false) => {
report.add("clone dotfiles", StepStatus::Skipped);
}
Err(e) => {
report.add("clone dotfiles", StepStatus::Failed(e.to_string())); report.add("clone dotfiles", StepStatus::Failed(e.to_string()));
return Ok(report); return Ok(report);
} }
@@ -110,7 +121,9 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
// threads exist. Subsequent Command::spawn calls (git, hooks) will // threads exist. Subsequent Command::spawn calls (git, hooks) will
// inherit these vars — keys are validated by parse_env_line before // inherit these vars — keys are validated by parse_env_line before
// reaching this point (null bytes and dangerous keys are rejected). // reaching this point (null bytes and dangerous keys are rejected).
unsafe { std::env::set_var(&k, &v); } unsafe {
std::env::set_var(&k, &v);
}
} }
Ok(None) => {} Ok(None) => {}
Err(e) => { Err(e) => {
@@ -129,7 +142,12 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
} }
// 6. Link dotfiles // 6. Link dotfiles
let link_opts = LinkOptions { force: opts.force, no_backup: false, dry_run: false, verbose: false }; let link_opts = LinkOptions {
force: opts.force,
no_backup: false,
dry_run: false,
verbose: false,
};
match link(&dotfiles_dir, &[], &link_opts) { match link(&dotfiles_dir, &[], &link_opts) {
Ok(state) => { Ok(state) => {
let count = state.entries.len(); let count = state.entries.len();
@@ -147,9 +165,14 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
HookRunner::new(dotfiles_dir.clone()) HookRunner::new(dotfiles_dir.clone())
}; };
for (phase, label) in [(HookPhase::Dot, "dot hooks"), (HookPhase::Setup, "setup hooks")] { for (phase, label) in [
(HookPhase::Dot, "dot hooks"),
(HookPhase::Setup, "setup hooks"),
] {
let phase_report = run_phase(&cfg.hooks, &phase, &runner); let phase_report = run_phase(&cfg.hooks, &phase, &runner);
let failed = phase_report.steps.iter() let failed = phase_report
.steps
.iter()
.filter(|s| matches!(s.status, notcore::StepStatus::Failed(_))) .filter(|s| matches!(s.status, notcore::StepStatus::Failed(_)))
.count(); .count();
let summary = if failed > 0 { let summary = if failed > 0 {
@@ -223,7 +246,12 @@ mod tests {
fn parse_env_line_blocks_ld_preload() { fn parse_env_line_blocks_ld_preload() {
let result = parse_env_line("LD_PRELOAD=/evil/lib.so"); let result = parse_env_line("LD_PRELOAD=/evil/lib.so");
assert!(result.is_err(), "expected error for LD_PRELOAD"); assert!(result.is_err(), "expected error for LD_PRELOAD");
assert!(result.unwrap_err().to_string().contains("sensitive env key")); assert!(
result
.unwrap_err()
.to_string()
.contains("sensitive env key")
);
} }
/// Issue #5: PATH must be blocked. /// Issue #5: PATH must be blocked.

View File

@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use notstrap::{prereqs, run, BootstrapOptions}; use notstrap::{BootstrapOptions, prereqs, run};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")] #[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")]
@@ -33,7 +33,12 @@ enum Cmd {
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let Cmd::Run { config, force, key_file, dotfiles } = cli.command; let Cmd::Run {
config,
force,
key_file,
dotfiles,
} = cli.command;
let opts = BootstrapOptions { let opts = BootstrapOptions {
config, config,
force, force,

View File

@@ -7,9 +7,18 @@ pub struct Prereq {
} }
const PREREQS: &[Prereq] = &[ const PREREQS: &[Prereq] = &[
Prereq { cmd: "nu", install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell" }, Prereq {
Prereq { cmd: "sops", install_hint: "brew install sops OR nix-env -iA nixpkgs.sops" }, cmd: "nu",
Prereq { cmd: "age", install_hint: "brew install age OR nix-env -iA nixpkgs.age" }, install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell",
},
Prereq {
cmd: "sops",
install_hint: "brew install sops OR nix-env -iA nixpkgs.sops",
},
Prereq {
cmd: "age",
install_hint: "brew install age OR nix-env -iA nixpkgs.age",
},
]; ];
pub fn check_prerequisites() -> Result<()> { pub fn check_prerequisites() -> Result<()> {

94
mise.toml Normal file
View File

@@ -0,0 +1,94 @@
[tools]
rust = "1.91.1"
[tasks]
# ── Quality Gates ──────────────────────────────────────────────────────────────
[tasks."all:check"]
description = "cargo check across workspace"
run = "cargo check --workspace"
[tasks."all:fmt"]
description = "Format all code"
run = "cargo fmt"
[tasks."all:fmt-check"]
description = "Check formatting (no changes)"
run = "cargo fmt --check"
[tasks."all:clippy"]
description = "Clippy with warnings as errors"
run = "cargo clippy --workspace -- -D warnings"
[tasks."all:test"]
description = "Run all tests via nextest"
run = "cargo nextest run --workspace --cargo-profile release"
[tasks."all:test-unit"]
description = "Run unit tests only (exclude integration)"
run = "cargo nextest run --workspace --lib --cargo-profile release"
[tasks."all:test-integration"]
description = "Run integration tests"
run = "cargo nextest run -p integration --cargo-profile release"
[tasks."all:ci"]
description = "Full CI gate: fmt-check + clippy + nextest"
run = """
#!/usr/bin/env bash
set -euo pipefail
echo " fmt "
cargo fmt --check
echo " clippy "
cargo clippy --workspace -- -D warnings
echo " dep-boundaries "
python3 scripts/check-dep-boundaries.py
echo " test "
cargo nextest run --workspace --cargo-profile release
echo " all gates passed"
"""
[tasks."all:ci-setup"]
description = "One-time setup: install cargo-nextest, cargo-deny"
run = """
#!/usr/bin/env bash
set -e
echo "Installing cargo-nextest..."
command -v cargo-nextest >/dev/null 2>&1 || ~/.local/bin/mise exec -- cargo install cargo-nextest --locked
echo "Installing cargo-deny..."
command -v cargo-deny >/dev/null 2>&1 || ~/.local/bin/mise exec -- cargo install cargo-deny --locked
echo " CI setup complete"
"""
[tasks."all:dep-boundaries"]
description = "Enforce crate dependency boundary rules"
run = "python3 scripts/check-dep-boundaries.py"
[tasks."all:deny"]
description = "Run cargo-deny license/security checks"
run = "cargo deny check"
# ── Dev Workflow ───────────────────────────────────────────────────────────────
[tasks."all:build"]
description = "Build all workspace crates"
run = "cargo build"
[tasks."all:build-release"]
description = "Build release binaries"
run = "cargo build --release"
[tasks."all:commit"]
description = "Stage all + commit. Usage: mise run all:commit -- 'message'"
run = """
#!/usr/bin/env bash
set -e
MSG="${1:?usage: mise run all:commit -- 'message'}"
git add -A
git commit -m "$MSG"
"""
[tasks."all:watch"]
description = "Watch for changes and run check + nextest"
run = "cargo watch -x 'check --workspace' -x 'nextest run --workspace --cargo-profile release'"

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Enforce crate dependency boundaries for the notfiles workspace:
- notcore must remain a leaf crate (no deps on other notfiles crates)
- notfiles, notsecrets, nothooks may depend on notcore but NOT on each other
- notstrap may depend on notcore + feature crates (it's the orchestrator)
Exits 0 if boundaries are clean, 1 if violations found.
"""
import json
import subprocess
import sys
LEAF = "notcore"
FEATURE_CRATES = {"notfiles", "notsecrets", "nothooks"}
ORCHESTRATOR = "notstrap"
WORKSPACE_CRATES = FEATURE_CRATES | {LEAF, ORCHESTRATOR}
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
capture_output=True, text=True, check=True,
)
meta = json.loads(result.stdout)
# Build name -> deps map for workspace members only
crate_deps: dict[str, set[str]] = {}
for pkg in meta["packages"]:
name = pkg["name"]
if name not in WORKSPACE_CRATES:
continue
deps = {d["name"] for d in pkg["dependencies"] if d["name"] in WORKSPACE_CRATES}
crate_deps[name] = deps
violations = []
# notcore must have no workspace deps
if crate_deps.get(LEAF):
violations.append(f"{LEAF} depends on workspace crates: {crate_deps[LEAF]}")
# feature crates must not depend on each other
for crate in FEATURE_CRATES:
forbidden = crate_deps.get(crate, set()) & FEATURE_CRATES
if forbidden:
violations.append(f"{crate} depends on sibling feature crates: {forbidden}")
if violations:
print("FAIL: crate dependency boundary violations detected:")
for v in violations:
print(f" - {v}")
sys.exit(1)
print("OK: crate dependency boundaries are clean")
print(f" {LEAF}: no workspace deps (leaf)")
for c in sorted(FEATURE_CRATES):
print(f" {c}: depends only on {LEAF}")
print(f" {ORCHESTRATOR}: orchestrator (unrestricted)")

View File

@@ -1,6 +1,6 @@
use notcore::{HookPhase, HookSpec, StepStatus}; use notcore::{HookPhase, HookSpec, StepStatus};
use nothooks::{run_phase, HookRunner}; use nothooks::{HookRunner, run_phase};
use notstrap::{run, BootstrapOptions}; use notstrap::{BootstrapOptions, run};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;

View File

@@ -3,9 +3,9 @@ use std::path::Path;
use tempfile::TempDir; use tempfile::TempDir;
use notcore::{HookPhase, HookSpec}; use notcore::{HookPhase, HookSpec};
use notfiles::{LinkOptions, link};
use nothooks::{HookResult, HookRunner}; use nothooks::{HookResult, HookRunner};
use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; use notsecrets::{AgeKeySource, FileSource, resolve_age_key};
use notfiles::{link, LinkOptions};
/// Test that notsecrets and nothooks can each be used independently /// Test that notsecrets and nothooks can each be used independently
/// in the same integration boundary — resolving an age key from a file /// in the same integration boundary — resolving an age key from a file
@@ -69,7 +69,12 @@ fn test_notfiles_respects_default_ignore() {
fs::write(pkg.join(".notfiles-state.toml"), "# state\n").unwrap(); fs::write(pkg.join(".notfiles-state.toml"), "# state\n").unwrap();
fs::write(pkg.join(".nothooks-state.toml"), "# state\n").unwrap(); fs::write(pkg.join(".nothooks-state.toml"), "# state\n").unwrap();
let opts = LinkOptions { force: false, no_backup: false, dry_run: false, verbose: false }; let opts = LinkOptions {
force: false,
no_backup: false,
dry_run: false,
verbose: false,
};
let state = link(d, &[], &opts).expect("link() failed"); let state = link(d, &[], &opts).expect("link() failed");
// foo.txt linked // foo.txt linked