diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7409b04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + - name: Cargo cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + - name: Build + run: cargo build --workspace --verbose + - name: Run tests + run: cargo test --workspace --verbose + - name: Build release + run: cargo build --workspace --release + - name: Package artifacts + run: | + mkdir -p dist + tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bread-${{ matrix.os }} + path: dist/*.tgz diff --git a/.gitignore b/.gitignore index 9529cb8..a253843 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ target/ Overview.md DAEMON.md +LUA_RUNTIME.md +CLAUDE_SPEC.md .claude CLAUDE.md .github -LUA_RUNTIME.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 313315f..c04bd41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -288,12 +309,20 @@ version = "0.1.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", + "chrono", "clap", + "dirs", + "flate2", "libc", "notify", + "reqwest", "serde", "serde_json", + "tar", + "tempfile", "tokio", + "toml", ] [[package]] @@ -304,6 +333,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +384,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,6 +420,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -422,6 +489,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +524,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,12 +579,53 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -606,12 +749,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -758,6 +941,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -766,11 +961,51 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1045,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "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]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "indexmap" version = "2.14.0" @@ -868,6 +1301,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1319,28 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,6 +1379,43 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -928,6 +1426,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -946,6 +1456,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1022,6 +1538,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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]] name = "mio" version = "0.8.11" @@ -1075,6 +1607,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1763,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1268,6 +1872,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1321,6 +1931,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,6 +1987,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1413,6 +2038,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -1442,6 +2078,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rtnetlink" version = "0.9.1" @@ -1503,6 +2179,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1512,12 +2209,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,6 +2326,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1633,6 +2374,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,6 +2412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1699,6 +2452,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1741,6 +2543,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1770,6 +2582,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2657,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2724,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,6 +2777,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[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]] name = "utf8parse" version = "0.2.2" @@ -1942,6 +2807,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2835,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2868,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2957,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +3010,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2237,6 +3235,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -2337,6 +3345,22 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2347,6 +3371,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -2434,6 +3481,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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 2.0.117", + "synstructure", +] + +[[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 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ab4e899..8216be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync", ] resolver = "2" @@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] } anyhow = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +git2 = "0.18" +dirs = "5.0" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +glob = "0.3" diff --git a/Documentation.md b/Documentation.md index c2ad50c..454f7a9 100644 --- a/Documentation.md +++ b/Documentation.md @@ -15,7 +15,7 @@ ## Overview -Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. +Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. - Daemon: long-running Rust process, source of truth for runtime state - Lua runtime: dedicated thread inside the daemon; automation logic lives here @@ -27,7 +27,7 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/breadd.toml` +- Daemon config: `~/.config/bread/bread.toml` - Lua entry point: `~/.config/bread/init.lua` - Lua modules: `~/.config/bread/modules/` @@ -465,7 +465,7 @@ Payload includes `online` and `interfaces`. ## Dictionary: IPC protocol -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON. Request: diff --git a/README.md b/README.md index ec62008..adbff67 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically. -> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development. +> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use. --- @@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that: Your automation lives in Lua. You subscribe to events, read state, and call APIs: ```lua -bread.on("bread.device.dock.connected", function() +local M = bread.module({ name = "dock", version = "1.0.0" }) + +bread.on("bread.device.dock.connected", function(event) bread.profile.activate("desk") bread.exec("waybar --config ~/.config/waybar/desk.jsonc") + bread.notify("Dock connected", { urgency = "low" }) end) -bread.on("bread.device.dock.disconnected", function() +bread.on("bread.device.dock.disconnected", function(event) bread.profile.activate("default") end) + +return M ``` --- @@ -40,6 +45,7 @@ end) breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision bread-cli/ CLI frontend — talks to breadd over a Unix socket bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource +bread-sync/ Sync engine — snapshot and restore system state via a Git remote packaging/ Arch PKGBUILD and systemd user service ``` @@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system bash scripts/install.sh ``` -Or do it step by step: +Or step by step: ```bash cargo build --release @@ -141,19 +147,16 @@ default_urgency = "normal" notify_send_path = "notify-send" [modules] -builtin = true # load built-in modules (monitors, devices, etc.) -disable = [] # list of built-in module names to disable +builtin = true # load built-in modules (monitors, devices, workspaces, binds) +disable = [] # list of built-in module names to disable ``` -Your automation lives in `~/.config/bread/init.lua`: +Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`: ```lua -- ~/.config/bread/init.lua -require("modules.devices") -require("modules.workspaces") - -bread.on("bread.system.startup", function() +bread.on("bread.system.startup", function(event) bread.profile.activate("default") end) ``` @@ -165,19 +168,144 @@ end) All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. ```bash +# Daemon +bread ping # Check daemon connectivity +bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health + +# Lua runtime bread reload # Hot-reload all Lua modules bread reload --watch # Watch config dir and reload on changes + +# State and events bread state # Dump full runtime state as JSON bread events # Stream live normalized events bread events --filter bread.device.* # Stream filtered events bread events --since 60 # Replay events from the last 60 seconds -bread modules # List loaded modules and status +bread emit # Manually fire an event (for testing) + +# Profiles bread profile-list # List defined profiles bread profile-activate # Activate a named profile -bread emit --data '{}' # Manually fire an event (for testing) -bread ping # Check daemon connectivity -bread health # Daemon version, uptime, PID -bread doctor # Diagnose daemon and module health + +# Modules +bread modules list # List installed modules and daemon status +bread modules install github:user/repo # Install from GitHub +bread modules install /local/path # Install from a local directory +bread modules remove # Remove an installed module +bread modules update [name] # Re-install one or all GitHub-sourced modules +bread modules info # Show full manifest and daemon status + +# Sync +bread sync init # Initialize sync for this machine +bread sync push # Snapshot and push current state to remote +bread sync pull # Pull and apply latest state from remote +bread sync pull --install-packages # Also install packages from snapshot +bread sync status # Show what has changed since last push +bread sync diff # Show file-level diff vs last commit +bread sync diff --remote # Show diff vs remote +bread sync machines # List known machines from sync repo +``` + +--- + +## Module system + +Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest. + +### Installing modules + +```bash +# From GitHub (downloads latest release tarball) +bread modules install github:someuser/bread-wifi + +# From a local path +bread modules install ~/src/my-module + +# From a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 +``` + +### Writing a module + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← required manifest + └── init.lua ← entry point +``` + +`bread.module.toml`: +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +`init.lua`: +```lua +local M = bread.module({ name = "wifi", version = "1.0.0" }) + +bread.on("bread.network.connected", function(event) + bread.log("Network up: " .. (event.data.interface or "unknown")) +end) + +return M +``` + +--- + +## Sync system + +Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Push current state +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Check what's pending +bread sync status +``` + +Configure what gets synced in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +The sync repo stores: + +``` +sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) ``` --- @@ -191,59 +319,66 @@ Events follow the namespace convention `bread...`. | `bread.system.startup` | Daemon fully initialized | | `bread.device.connected` | Any device attached | | `bread.device.disconnected` | Any device removed | -| `bread.device.changed` | Any device changed | -| `bread.device..connected` | Device attached by class | -| `bread.device..disconnected` | Device removed by class | -| `bread.device..changed` | Device changed by class | +| `bread.device.dock.connected` | Dock attached | +| `bread.device.dock.disconnected` | Dock removed | +| `bread.device.keyboard.connected` | Keyboard attached | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | -| `bread.workspace.created` | Workspace created | -| `bread.workspace.destroyed` | Workspace destroyed | | `bread.window.focus.changed` | Focused window changed | -| `bread.window.focused` | Focus moved (address only) | | `bread.window.opened` | Window opened | | `bread.window.closed` | Window closed | -| `bread.window.moved` | Window moved workspaces | | `bread.power.ac.connected` | AC adapter plugged in | | `bread.power.ac.disconnected` | AC adapter unplugged | | `bread.power.battery.low` | Battery ≤ 20% | | `bread.power.battery.very_low` | Battery ≤ 10% | | `bread.power.battery.critical` | Battery ≤ 5% | | `bread.power.battery.full` | Battery at 100% | -| `bread.power.changed` | Power state changed (fallback) | -| `bread.network.connected` | Network came online | -| `bread.network.disconnected` | Network went offline | -| `bread.profile.activated` | Profile switched via IPC | -| `bread.notify.sent` | Notification dispatched | -| `bread.state.changed.` | State watch fired | -| `bread.hyprland.event` | Raw Hyprland event (unhandled kind) | +| `bread.network.connected` | Network interface came online | +| `bread.network.disconnected` | Network interface went offline | +| `bread.profile.activated` | Profile switched | +| `bread.notify.sent` | Desktop notification dispatched | --- ## Lua API -Full reference and usage notes live in [documentation.md](documentation.md). This section is a compact quick-reference to every API that exists today. +### Modules -Practical walkthroughs and ports from existing Hyprland configs live in [Examples.md](Examples.md). +Every module file must declare itself. The declaration is used for dependency ordering and status tracking. + +```lua +local M = bread.module({ + name = "my-module", + version = "1.0.0", + after = { "bread.devices" }, -- load after this module +}) + +-- ... module body ... + +return M +``` ### Events ```lua --- Subscribe to an event; returns a numeric ID +-- Subscribe to events; returns a subscription ID local id = bread.on("bread.monitor.connected", function(event) - print(event.data.name) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) end) -- Unsubscribe by ID bread.off(id) --- Subscribe once, then auto-unsubscribe +-- Subscribe once, auto-unsubscribe after first delivery bread.once("bread.system.startup", function(event) - -- runs exactly once + bread.profile.activate("default") end) --- Subscribe with a predicate filter +-- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) return event.data.class == "keyboard" end, function(event) @@ -252,38 +387,36 @@ end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) +``` --- Wait for an event (coroutine-only) -bread.spawn(function() - local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) - if event then - bread.log("dock arrived") - end -end) +Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character): +```lua +bread.on("bread.device.*", handler) -- matches bread.device.dock.connected +bread.on("bread.device.**", handler) -- matches any depth under bread.device ``` ### State ```lua --- Read a value from runtime state by dot-separated path +-- Read from runtime state by dot-separated path local monitors = bread.state.get("monitors") -local workspace = bread.state.get("active_workspace") -local power = bread.state.get("power") -local devices = bread.state.get("devices") +local online = bread.state.get("network.online") --- Watch a state key and fire on changes -bread.state.watch("active_workspace", function(new, old) - print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +-- Typed shorthands +local monitors = bread.state.monitors() +local workspace = bread.state.active_workspace() +local window = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() + +-- Watch a state path for changes +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end end) - --- Convenience helpers -local monitors = bread.state.monitors() -local active_ws = bread.state.active_workspace() -local active_win = bread.state.active_window() -local devices = bread.state.devices() -local power = bread.state.power() -local network = bread.state.network() -local profile = bread.state.profile() ``` ### Profiles @@ -296,81 +429,99 @@ bread.profile.activate("default") ### Execution and notifications ```lua --- Fire-and-forget: returns immediately, process runs in background +-- Fire-and-forget shell command bread.exec("kitty") --- Desktop notification -bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +-- Desktop notification (uses notify-send) +bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" }) +bread.notify("Simple message") -- title defaults to "bread" ``` ### Timers ```lua -- Run once after a delay (ms) -bread.after(500, function() +local id = bread.after(500, function() bread.exec("some-delayed-command") end) --- Run on a repeating interval (ms); returns a timer ID +-- Run on a repeating interval (ms) local id = bread.every(60000, function() bread.log("tick") end) + +-- Cancel either kind bread.cancel(id) -- Debounce a rapidly-firing handler local fn = bread.debounce(200, function(event) reconfigure_monitors() end) +bread.on("bread.monitor.*", fn) +``` --- Cancel a timer -local timer_id = bread.after(500, function() bread.exec("echo ready") end) -bread.cancel(timer_id) +### Wait (inside coroutines) + +```lua +-- Yield until a matching event arrives +local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) +if event then + -- dock arrived within 5 seconds +end +``` + +### Machine and filesystem + +```lua +-- Machine identity (from sync.toml, falls back to hostname) +local name = bread.machine.name() +local tags = bread.machine.tags() -- array of strings +local ok = bread.machine.has_tag("laptop") + +-- Filesystem helpers (~ is expanded) +bread.fs.write("~/.config/some/file", "content") +local content = bread.fs.read("~/.config/some/file") -- nil if not found +local exists = bread.fs.exists("~/some/path") +local abs = bread.fs.expand("~/some/path") ``` ### Logging ```lua -bread.log("Module loaded") -bread.warn("Unexpected state") -bread.error("Something failed") +bread.log("Module loaded") -- info level +bread.warn("Unexpected state") -- warn level +bread.error("Something failed") -- error level +``` -### Hyprland +### Hyprland bindings ```lua +-- Dispatch a Hyprland command bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") -local win = bread.hyprland.active_window() -local monitors = bread.hyprland.monitors() +-- Query compositor state +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() local workspaces = bread.hyprland.workspaces() -local clients = bread.hyprland.clients() +local clients = bread.hyprland.clients() --- Raw Hyprland event filtering (kind matches hyprland event name) -bread.hyprland.on_raw("openwindow", function(event) - bread.log(event.data.raw) +-- Subscribe to raw Hyprland events (bypass normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw is the unparsed string from Hyprland's event socket end) ``` -### Modules +### Module-scoped storage + +Survives hot reload; does not survive daemon restart. ```lua -local M = bread.module({ name = "my.module", version = "0.1.0", after = { "bread.devices" } }) - -function M.on_load() - bread.on("bread.device.*", function(event) - bread.log(event.event) - end) -end - -function M.on_unload() - bread.log("unloaded") -end - -M.store.set("last_seen", os.time()) -local last = M.store.get("last_seen") - -return M -``` +M.store.set("last_profile", "docked") +local p = M.store.get("last_profile") -- "docked" ``` --- @@ -389,9 +540,24 @@ Response: { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } ``` -Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`. +Available methods: -`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. +| Method | Description | +|--------|-------------| +| `ping` | Connectivity check | +| `health` | Version, uptime, PID, adapter status | +| `state.get` | Read a value from `RuntimeState` by dotted key path | +| `state.dump` | Return the full `RuntimeState` as JSON | +| `modules.list` | List all loaded modules and their status | +| `modules.reload` | Hot-reload the Lua runtime | +| `profile.list` | List defined profiles | +| `profile.activate` | Switch active profile | +| `events.subscribe` | Upgrade connection to streaming mode | +| `events.replay` | Replay buffered events from the last N ms | +| `emit` | Inject a synthetic event into the pipeline | +| `sync.status` | Return sync initialization state and machine info | + +`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects. --- @@ -399,7 +565,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 69a2c49..7d40088 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -7,12 +7,24 @@ edition = "2021" name = "bread" path = "src/main.rs" +[lib] +name = "bread_cli" +path = "src/lib.rs" + [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true anyhow.workspace = true +chrono.workspace = true +dirs.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" +toml = "0.8" +reqwest = { version = "0.11", features = ["json"] } +flate2 = "1.0" +tar = "0.4" +tempfile.workspace = true diff --git a/bread-cli/src/lib.rs b/bread-cli/src/lib.rs new file mode 100644 index 0000000..72bcce2 --- /dev/null +++ b/bread-cli/src/lib.rs @@ -0,0 +1,2 @@ +/// Module management (install, remove, list, update, info). +pub mod modules_mgmt; diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0ca91df..eadd679 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,9 +1,16 @@ -use anyhow::Result; +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, + SyncRepo, +}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; -use std::io; +use std::io::{self, Write as IoWrite}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -47,8 +54,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,14 +86,70 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ModulesCommand { + /// Install a module from a source + Install { + /// Source: github:user/repo[@ref] or /path/to/dir + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, + /// List all installed modules + List, + /// Update one or all installed modules + Update { + /// Module name (omit to update all) + name: Option, + }, + /// Show full manifest details for a module + Info { name: String }, +} + +#[derive(Subcommand, Debug)] +enum SyncCommand { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Custom commit message + #[arg(long)] + message: Option, + }, + /// Pull and apply latest state + Pull { + /// Also install packages from manifest + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs last commit (or vs remote with --remote) + Diff { + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let socket = daemon_socket_path(); - match &cli.command { + match cli.command { Commands::Reload { watch } => { - if *watch { + if watch { watch_reload(&socket).await?; } else { let response = send_request(&socket, "modules.reload", json!({})).await?; @@ -86,19 +157,14 @@ async fn main() -> Result<()> { } } Commands::State { path, json } => { - if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; + let response = if let Some(ref path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + if json { print_json(&response)?; } else { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } @@ -108,22 +174,25 @@ async fn main() -> Result<()> { fields, since, } => { - stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; + stream_events(&socket, filter, json, fields, since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { subcommand } => { + handle_modules_cmd(subcommand, &socket).await?; + } + Commands::Sync { subcommand } => { + handle_sync_cmd(subcommand, &socket).await?; } Commands::ProfileList => { let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?; + let response = + send_request(&socket, "profile.activate", json!({ "name": name })).await?; print_json(&response)?; } Commands::Emit { event, data } => { - let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); + let parsed = serde_json::from_str::(&data).unwrap_or_else(|_| json!({})); let response = send_request( &socket, "emit", @@ -144,7 +213,7 @@ async fn main() -> Result<()> { print_json(&response)?; } Commands::Doctor { json } => { - if *json { + if json { let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { @@ -156,6 +225,580 @@ async fn main() -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Module subcommands +// --------------------------------------------------------------------------- + +async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { + let mods_dir = modules_mgmt::modules_dir(); + + match cmd { + ModulesCommand::Install { source } => { + let manifest = + install_module(&source, &mods_dir).await?; + println!("installed {} v{}", manifest.name, manifest.version); + try_daemon_reload(socket).await; + } + + ModulesCommand::Remove { name, yes } => { + let module_dir = mods_dir.join(&name); + if !module_dir.exists() { + eprintln!("bread: module '{}' is not installed", name); + std::process::exit(1); + } + if !yes { + print!("remove {}? (y/n): ", name); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + modules_mgmt::remove_module(&name, &mods_dir)?; + println!("removed {}", name); + try_daemon_reload(socket).await; + } + + ModulesCommand::List => { + let modules = modules_mgmt::list_modules(&mods_dir)?; + // Try to get daemon module status + let daemon_statuses = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let status = v.get("status").and_then(Value::as_str)?.to_string(); + Some((name, status)) + }) + .collect::>(), + Err(_) => std::collections::HashMap::new(), + }; + for m in &modules { + let status = daemon_statuses + .get(&m.name) + .map(String::as_str) + .unwrap_or("unknown"); + println!(" {:20} {:10} {:10} {}", m.name, m.version, status, m.source); + } + } + + ModulesCommand::Update { name } => { + let targets: Vec<_> = if let Some(n) = name { + vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?] + } else { + modules_mgmt::list_modules(&mods_dir)? + }; + + let mut updated_any = false; + for manifest in targets { + if manifest.source.starts_with("github:") { + let old_ver = manifest.version.clone(); + let new_manifest = + install_module(&manifest.source, &mods_dir).await?; + if new_manifest.version == old_ver { + println!("{} already up to date", manifest.name); + } else { + println!("updated {} v{} → v{}", manifest.name, old_ver, new_manifest.version); + updated_any = true; + } + } else { + eprintln!( + "cannot update local module '{}' — reinstall manually", + manifest.name + ); + } + } + if updated_any { + try_daemon_reload(socket).await; + } + } + + ModulesCommand::Info { name } => { + let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?; + let status = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .and_then(|arr| { + arr.iter() + .find(|v| v.get("name").and_then(Value::as_str) == Some(&m.name)) + .and_then(|v| v.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()), + Err(_) => "unknown".to_string(), + }; + println!("name: {}", m.name); + println!("version: {}", m.version); + println!("description: {}", m.description); + println!("author: {}", m.author); + println!("source: {}", m.source); + println!("installed_at: {}", m.installed_at); + println!("status: {}", status); + } + } + Ok(()) +} + +async fn install_module( + source: &str, + mods_dir: &std::path::Path, +) -> Result { + match modules_mgmt::parse_source(source)? { + modules_mgmt::InstallSource::LocalPath(path) => { + modules_mgmt::install_from_local(&path, source, mods_dir) + } + modules_mgmt::InstallSource::GitHub { user, repo, git_ref } => { + install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await + } + } +} + +async fn install_from_github( + user: &str, + repo: &str, + git_ref: Option<&str>, + source_str: &str, + mods_dir: &Path, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let ref_to_use = match git_ref { + Some(r) => r.to_string(), + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client + .get(&url) + .send() + .await + .context("failed to reach GitHub API")? + .json() + .await + .context("failed to parse GitHub API response")?; + resp.get("default_branch") + .and_then(Value::as_str) + .unwrap_or("main") + .to_string() + } + }; + + let tarball_url = + format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let bytes = client + .get(&tarball_url) + .send() + .await + .context("failed to download module archive")? + .bytes() + .await + .context("failed to read module archive")?; + + let tmp = tempfile::tempdir()?; + let mut archive = + tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + archive.unpack(tmp.path())?; + + // GitHub extracts to a single subdirectory (e.g. "user-repo-sha/") + let root = std::fs::read_dir(tmp.path())? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?; + + modules_mgmt::install_from_local(&root, source_str, mods_dir) +} + +/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. +async fn try_daemon_reload(socket: &Path) { + match send_request(socket, "modules.reload", json!({})).await { + Ok(_) => {} + Err(_) => { + eprintln!("note: daemon not running; reload manually with 'bread reload'"); + } + } +} + +// --------------------------------------------------------------------------- +// Sync subcommands +// --------------------------------------------------------------------------- + +async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { + let cfg_dir = bread_config_dir(); + + match cmd { + SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?, + SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?, + SyncCommand::Pull { install_packages } => { + cmd_sync_pull(&cfg_dir, install_packages, socket).await? + } + SyncCommand::Status => cmd_sync_status(&cfg_dir).await?, + SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?, + SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?, + } + Ok(()) +} + +async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { + let sync_toml = cfg_dir.join("sync.toml"); + if sync_toml.exists() { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + sync_toml.display() + ); + std::process::exit(1); + } + + let remote_url = match remote { + Some(u) => u, + None => { + print!("Sync remote URL (git remote or path): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + line.trim().to_string() + } + }; + + let default_hostname = machine::hostname(); + print!("Machine name [{}]: ", default_hostname); + io::stdout().flush()?; + let mut name_line = String::new(); + io::stdin().read_line(&mut name_line)?; + let machine_name = { + let t = name_line.trim(); + if t.is_empty() { + default_hostname + } else { + t.to_string() + } + }; + + print!("Machine tags (comma-separated, e.g. mobile,battery): "); + io::stdout().flush()?; + let mut tags_line = String::new(); + io::stdin().read_line(&mut tags_line)?; + let tags: Vec = tags_line + .trim() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + + let config = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: remote_url.clone(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: machine_name.clone(), + tags, + }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + }; + config.save(cfg_dir)?; + + // If it looks like a URL (not a local path), check if it exists + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + println!(" remote: {}", remote_url); + println!(" config: {}", cfg_dir.join("sync.toml").display()); + Ok(()) +} + +async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + // Clone or open the local sync repo + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir( + cfg_dir, + &bread_dest, + &[ + // Don't recurse into the sync repo itself + ".git".to_string(), + ], + )?; + + // Snapshot delegate configs + let configs_dir = repo_path.join("configs"); + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() { + let dst = configs_dir.join(basename); + delegates::sync_dir(src_path, &dst, &config.delegates.exclude)?; + } + } + + // Snapshot packages + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + if let Err(e) = packages::snapshot(manager, &dest_file) { + eprintln!("bread: warning: package snapshot for {} failed: {}", manager, e); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + let profile = + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); + profile.write(&machines_dir)?; + + // Set remote and commit + repo.set_remote("origin", &config.remote.url)?; + let commit_msg = message.unwrap_or_else(|| { + format!( + "sync: {} {}", + config.machine.name, + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ") + ) + }); + + if repo.commit(&commit_msg)?.is_none() { + println!("nothing to push — already up to date"); + return Ok(()); + } + + repo.push("origin", &config.remote.branch)?; + + println!("pushed sync for {}", config.machine.name); + println!(" bread config: {}", cfg_dir.display()); + if !config.delegates.include.is_empty() { + println!(" delegates: {}", config.delegates.include.len()); + } + if config.packages.enabled { + println!(" packages: {}", config.packages.managers.join(", ")); + } + Ok(()) +} + +async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + match repo.pull("origin", &config.remote.branch) { + Ok(()) => {} + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } + + // Apply bread/ → ~/.config/bread/ + let bread_src = repo_path.join("bread"); + if bread_src.exists() { + delegates::sync_dir(&bread_src, cfg_dir, &[])?; + } + + // Apply configs/ entries back to their original locations + let configs_dir = repo_path.join("configs"); + if configs_dir.exists() { + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, dst_path) in &delegate_paths { + let src = configs_dir.join(basename); + if src.exists() { + delegates::sync_dir(&src, dst_path, &config.delegates.exclude)?; + } + } + } + + // Package installs + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + if install_packages { + run_package_installs(&packages_dir, &config.packages.managers)?; + } else { + // Check if packages differ + let has_package_files = config.packages.managers.iter().any(|m| { + packages_dir.join(format!("{m}.txt")).exists() + }); + if has_package_files { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + } + + // Notify daemon + try_daemon_reload(socket).await; + + println!("applied sync for {}", config.machine.name); + Ok(()) +} + +async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + println!("bread sync status"); + println!(" not yet pushed"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + // Fetch remote refs without merging + let _ = repo.fetch("origin", &config.remote.branch); + + let last_push = repo + .last_commit_time() + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "never".to_string()); + + println!("bread sync status"); + println!(" machine {}", config.machine.name); + println!(" remote {}", config.remote.url); + println!(" last push {}", last_push); + + let local_changes = repo.local_changes()?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; + println!(); + println!("remote changes (not yet pulled):"); + if remote_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &remote_changes { + println!(" {} {}", ch, path); + } + } + + Ok(()) +} + +async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: sync repo not initialized. Run: bread sync push"); + std::process::exit(1); + } + + let repo = SyncRepo::open(&repo_path)?; + + let diff = if vs_remote { + repo.set_remote("origin", &config.remote.url)?; + let _ = repo.fetch("origin", &config.remote.branch); + repo.remote_diff("origin", &config.remote.branch)? + } else { + repo.working_diff()? + }; + + print!("{}", diff); + Ok(()) +} + +async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { + let _ = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + let machines_dir = repo_path.join("machines"); + + let profiles = machine::MachineProfile::list(&machines_dir)?; + for p in &profiles { + let tags = if p.tags.is_empty() { + String::new() + } else { + format!(" tags: {}", p.tags.join(", ")) + }; + println!(" {:20} last sync: {}{}", p.name, &p.last_sync[..16], tags); + } + Ok(()) +} + +fn load_sync_config(cfg_dir: &Path) -> Result { + match SyncConfig::load(cfg_dir) { + Ok(c) => Ok(c), + Err(_) => { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + } +} + +fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> { + for manager in managers { + let file = packages_dir.join(format!("{manager}.txt")); + if !file.exists() { + continue; + } + let content = std::fs::read_to_string(&file)?; + match manager.as_str() { + "pacman" => { + let pkgs = packages::parse_pacman(&content); + if pkgs.is_empty() { + continue; + } + let mut cmd = std::process::Command::new("sudo"); + cmd.args(["pacman", "-S", "--needed"]).args(&pkgs); + let _ = cmd.status(); + } + "pip" => { + let mut cmd = std::process::Command::new("pip"); + cmd.args(["install", "--user", "-r"]) + .arg(file.to_str().unwrap_or("")); + let _ = cmd.status(); + } + "npm" => { + let pkgs = packages::parse_npm(&content); + for pkg in pkgs { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); + } + } + "cargo" => { + let pkgs = packages::parse_cargo(&content); + for pkg in pkgs { + let _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); + } + } + _ => {} + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers (shared with original commands) +// --------------------------------------------------------------------------- + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -164,7 +807,18 @@ fn daemon_socket_path() -> PathBuf { } async fn send_request(socket: &Path, method: &str, params: Value) -> Result { - let stream = UnixStream::connect(socket).await?; + let stream = UnixStream::connect(socket).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::ConnectionRefused + { + anyhow::anyhow!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ) + } else { + e.into() + } + })?; + let (read_half, mut write_half) = stream.into_split(); let request = json!({ "id": "1", @@ -195,7 +849,9 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + let replay = + send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })) + .await?; if let Some(list) = replay.as_array() { for item in list { if raw_json { @@ -303,9 +959,7 @@ fn format_timestamp(ms: u64) -> String { let mut tm: libc::tm = std::mem::zeroed(); let t = secs as libc::time_t; libc::localtime_r(&t, &mut tm); - tm.tm_hour as u64 * 3600 - + tm.tm_min as u64 * 60 - + tm.tm_sec as u64 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -346,8 +1000,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { continue; } - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} @@ -384,10 +1036,20 @@ fn render_doctor(health: &Value) { println!("bread doctor"); let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false); let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0); - let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown"); - let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); + let version = health + .get("version") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let uptime_ms = health + .get("uptime_ms") + .and_then(Value::as_u64) + .unwrap_or(0); let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); - println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); + println!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..17c0a7b --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,175 @@ +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Contents of `bread.module.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub author: String, + pub source: String, + pub installed_at: String, +} + +/// Parsed install source. +pub enum InstallSource { + GitHub { + user: String, + repo: String, + git_ref: Option, + }, + LocalPath(PathBuf), +} + +/// Parse a source string into an `InstallSource`. +pub fn parse_source(source: &str) -> Result { + if let Some(rest) = source.strip_prefix("github:") { + let (repo_part, ref_part) = rest + .split_once('@') + .map(|(r, v)| (r, Some(v.to_string()))) + .unwrap_or((rest, None)); + let (user, repo) = repo_part.split_once('/').ok_or_else(|| { + anyhow::anyhow!( + "bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'", + source + ) + })?; + Ok(InstallSource::GitHub { + user: user.to_string(), + repo: repo.to_string(), + git_ref: ref_part, + }) + } else if source.starts_with('/') + || source.starts_with("./") + || source.starts_with("../") + || source.starts_with('~') + { + let expanded = bread_sync::config::expand_path(source); + Ok(InstallSource::LocalPath(expanded)) + } else { + bail!( + "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + source + ) + } +} + +/// Install a module from a local directory into `modules_dir`. +/// `source_str` is the original source string recorded in the manifest. +pub fn install_from_local(src: &Path, source_str: &str, modules_dir: &Path) -> Result { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + bail!( + "bread: no bread.module.toml found in {}", + src.display() + ); + } + + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let mut manifest: ModuleManifest = + toml::from_str(&raw).context("failed to parse bread.module.toml")?; + + manifest.source = source_str.to_string(); + manifest.installed_at = Utc::now().to_rfc3339(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + fs::remove_dir_all(&dest) + .with_context(|| format!("failed to remove existing module at {}", dest.display()))?; + } + copy_dir(src, &dest)?; + + // Rewrite the manifest with the updated fields. + let manifest_dest = dest.join("bread.module.toml"); + let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?; + fs::write(&manifest_dest, out) + .with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?; + + Ok(manifest) +} + +/// Remove a module directory from `modules_dir`. +pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> { + let module_dir = modules_dir.join(name); + if !module_dir.exists() { + bail!("bread: module '{}' is not installed", name); + } + fs::remove_dir_all(&module_dir) + .with_context(|| format!("failed to remove {}", module_dir.display())) +} + +/// List all installed modules in `modules_dir`. +pub fn list_modules(modules_dir: &Path) -> Result> { + if !modules_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(modules_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("bread.module.toml"); + if manifest_path.exists() { + if let Ok(m) = read_manifest_file(&manifest_path) { + out.push(m); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Read a module manifest by name. +pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result { + let manifest_path = modules_dir.join(name).join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: module '{}' is not installed", name); + } + read_manifest_file(&manifest_path) +} + +/// Read and parse a `bread.module.toml` file. +pub fn read_manifest_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse module manifest") +} + +/// Returns the default modules directory. +pub fn modules_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread").join("modules"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread").join("modules"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join("bread") + .join("modules"); + } + PathBuf::from(".config/bread/modules") +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path) + .with_context(|| format!("failed to copy {} to {}", src_path.display(), dst_path.display()))?; + } + } + Ok(()) +} diff --git a/bread-cli/tests/modules.rs b/bread-cli/tests/modules.rs new file mode 100644 index 0000000..d05374c --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,133 @@ +use bread_cli::modules_mgmt; +use std::fs; +use tempfile::TempDir; + +/// Helper: create a minimal valid module directory in `dir` with given name. +fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf { + let module_dir = dir.join(name); + fs::create_dir_all(&module_dir).unwrap(); + let manifest = format!( + r#"name = "{name}" +version = "{version}" +description = "Test module" +author = "test" +source = "/tmp/test" +installed_at = "" +"# + ); + fs::write(module_dir.join("bread.module.toml"), manifest).unwrap(); + fs::write(module_dir.join("init.lua"), "-- test\n").unwrap(); + module_dir +} + +#[test] +fn install_from_local_succeeds_with_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "mymod", "1.2.3"); + let src = src_tmp.path().join("mymod"); + + let result = + modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path()); + + assert!(result.is_ok(), "install failed: {:?}", result.err()); + let manifest = result.unwrap(); + assert_eq!(manifest.name, "mymod"); + assert_eq!(manifest.version, "1.2.3"); + + // Module directory must exist in modules dir + assert!(modules_tmp.path().join("mymod").exists()); + assert!(modules_tmp.path().join("mymod").join("bread.module.toml").exists()); + assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); +} + +#[test] +fn install_from_local_fails_without_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + // No bread.module.toml in src + let src = src_tmp.path(); + fs::write(src.join("init.lua"), "-- no manifest\n").unwrap(); + + let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("bread.module.toml"), + "expected error about bread.module.toml, got: {msg}" + ); +} + +#[test] +fn remove_deletes_module_directory() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "delme", "0.1.0"); + + // Verify it exists before removal + assert!(modules_tmp.path().join("delme").exists()); + + let result = modules_mgmt::remove_module("delme", modules_tmp.path()); + assert!(result.is_ok(), "remove failed: {:?}", result.err()); + assert!(!modules_tmp.path().join("delme").exists()); +} + +#[test] +fn remove_nonexistent_errors() { + let modules_tmp = TempDir::new().unwrap(); + let result = modules_mgmt::remove_module("ghost", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("ghost"), "expected error mentioning module name, got: {msg}"); +} + +#[test] +fn list_reads_manifests_from_disk() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "alpha", "1.0.0"); + make_module_dir(modules_tmp.path(), "beta", "2.0.0"); + + // Add a non-module dir (no manifest) — should be ignored + fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap(); + + let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].name, "alpha"); + assert_eq!(modules[1].name, "beta"); +} + +#[test] +fn manifest_written_correctly_on_install() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "installtest", "3.0.0"); + let src = src_tmp.path().join("installtest"); + + let manifest = + modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path()) + .unwrap(); + + // All required fields must be present and non-empty + assert_eq!(manifest.name, "installtest"); + assert_eq!(manifest.version, "3.0.0"); + assert!(!manifest.description.is_empty()); + assert!(!manifest.author.is_empty()); + assert_eq!(manifest.source, "github:test/installtest"); + assert!(!manifest.installed_at.is_empty()); + + // installed_at must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at); + assert!( + parsed.is_ok(), + "installed_at '{}' is not valid RFC 3339", + manifest.installed_at + ); + + // Verify the on-disk manifest also has all fields + let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap(); + assert_eq!(on_disk.name, manifest.name); + assert_eq!(on_disk.installed_at, manifest.installed_at); + assert_eq!(on_disk.source, "github:test/installtest"); +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..232b592 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +git2.workspace = true +dirs.workspace = true +chrono.workspace = true +glob.workspace = true +toml = "0.8" +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..7d37899 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,88 @@ +# bread-sync + +Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote. + +## Purpose + +`bread-sync` provides the library backing `bread sync` commands. It handles: + +- **Git operations** — clone, commit, push, pull, fetch, diff via `git2` +- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages) +- **Delegate file sync** — rsync-style directory copy with glob excludes +- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo +- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp + +## Public API + +### `config` + +```rust +SyncConfig::load(config_dir: &Path) -> Result +SyncConfig::save(&self, config_dir: &Path) -> Result<()> +SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/ +bread_config_dir() -> PathBuf // ~/.config/bread/ +expand_path(path: &str) -> PathBuf // expands ~/ +``` + +### `git` + +```rust +SyncRepo::init(path: &Path) -> Result +SyncRepo::open(path: &Path) -> Result +SyncRepo::clone_from(url: &str, path: &Path) -> Result +SyncRepo::open_or_clone(url: &str, path: &Path) -> Result +SyncRepo::commit(&self, message: &str) -> Result> // None = nothing to commit +SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only +SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::is_clean(&self) -> Result +SyncRepo::local_changes(&self) -> Result> +SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result> +SyncRepo::working_diff(&self) -> Result +SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result +SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()> +SyncRepo::last_commit_time(&self) -> Option> +``` + +### `delegates` + +```rust +sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> +resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> +``` + +### `machine` + +```rust +MachineProfile::new(name: String, tags: Vec) -> MachineProfile +MachineProfile::write(&self, machines_dir: &Path) -> Result<()> +MachineProfile::read(machines_dir: &Path, name: &str) -> Result +MachineProfile::list(machines_dir: &Path) -> Result> +hostname() -> String +``` + +### `packages` + +```rust +snapshot(manager: &str, dest: &Path) -> Result // false = manager not found (non-fatal) +parse_pacman(content: &str) -> Vec +parse_pip(content: &str) -> Vec +parse_npm(content: &str) -> Vec +parse_cargo(content: &str) -> Vec +``` + +## Sync repo layout + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← snapshot of ~/.config/bread/ +├── configs/ +│ └── / ← delegate paths +├── machines/ +│ └── .toml ← per-machine profiles +└── packages/ + ├── pacman.txt + ├── pip.txt + ├── npm.txt + └── cargo.txt +``` diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..55a8dd3 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,135 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + pub remote: RemoteConfig, + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConfig { + pub url: String, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineConfig { + pub name: String, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub managers: Vec, +} + +fn default_true() -> bool { + true +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: vec![ + "pacman".to_string(), + "pip".to_string(), + "npm".to_string(), + "cargo".to_string(), + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load sync config from the given bread config directory. + pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("sync.toml"); + let raw = fs::read_to_string(&path) + .with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?; + toml::from_str(&raw).context("failed to parse sync.toml") + } + + /// Save sync config to the given bread config directory. + pub fn save(&self, config_dir: &Path) -> Result<()> { + let path = config_dir.join("sync.toml"); + fs::create_dir_all(config_dir)?; + let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`). + pub fn local_repo_path() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + return data_dir.join("bread").join("sync-repo"); + } + // Fallback using $HOME + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("bread") + .join("sync-repo"); + } + PathBuf::from(".local/share/bread/sync-repo") + } +} + +/// Returns the bread config directory (`~/.config/bread/`). +pub fn bread_config_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("bread"); + } + PathBuf::from(".config/bread") +} + +/// Expand `~` to the home directory in a path string. +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..2c59792 --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use glob::Pattern; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::expand_path; + +/// Copy all files from `src` into `dst`, mirroring the directory tree. +/// Files present in `dst` but not in `src` are deleted (rsync-style). +/// Files matching any `exclude` glob are skipped. +pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> { + let patterns: Vec = exclude + .iter() + .filter_map(|g| Pattern::new(g).ok()) + .collect(); + + fs::create_dir_all(dst)?; + sync_dir_inner(src, dst, src, &patterns) +} + +fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> { + // Remove files in dst that don't exist in src. + if dst.exists() { + for entry in fs::read_dir(dst)? { + let entry = entry?; + let rel = entry.path().strip_prefix(dst).unwrap_or(&entry.path()).to_path_buf(); + let src_counterpart = src.join(&rel); + if !src_counterpart.exists() { + let p = entry.path(); + if p.is_dir() { + let _ = fs::remove_dir_all(&p); + } else { + let _ = fs::remove_file(&p); + } + } + } + } + + if !src.exists() { + return Ok(()); + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let rel = src_path.strip_prefix(root).unwrap_or(&src_path); + + if is_excluded(rel, root, patterns) { + continue; + } + + let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path)); + + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + sync_dir_inner(&src_path, &dst_path, root, patterns)?; + } else { + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool { + let rel_str = rel.to_string_lossy(); + let file_name = rel + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + for pat in patterns { + // Match against full relative path or just filename + if pat.matches(&rel_str) || pat.matches(&file_name) { + return true; + } + // For directory-name patterns (e.g. "**/.git"), also check component names + if let Some(pat_str) = pat.as_str().strip_prefix("**/") { + for component in rel.components() { + if let std::path::Component::Normal(name) = component { + if Pattern::new(pat_str) + .map(|p| p.matches(&name.to_string_lossy())) + .unwrap_or(false) + { + return true; + } + } + } + } + } + false +} + +/// Resolve delegate paths from the config (expanding `~`). +pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { + includes + .iter() + .map(|s| { + let expanded = expand_path(s); + let basename = expanded + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.clone()); + (basename, expanded) + }) + .collect() +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..a3740f8 --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,366 @@ +use anyhow::{Context, Result}; +use git2::{ + build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, + Repository, Signature, StatusOptions, +}; +use std::path::{Path, PathBuf}; + +/// Wraps a git2 repository with sync-specific operations. +pub struct SyncRepo { + repo: Repository, + pub path: PathBuf, +} + +impl SyncRepo { + /// Open an existing repository at `path`. + pub fn open(path: &Path) -> Result { + let repo = Repository::open(path) + .with_context(|| format!("failed to open git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Clone `url` into `path`. + pub fn clone_from(url: &str, path: &Path) -> Result { + let fetch_opts = make_fetch_options(); + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fetch_opts); + let repo = builder + .clone(url, path) + .with_context(|| format!("failed to clone {} into {}", url, path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Open the repo at `path` if it exists; otherwise clone from `url`. + pub fn open_or_clone(url: &str, path: &Path) -> Result { + if path.exists() { + Self::open(path) + } else { + std::fs::create_dir_all(path)?; + Self::clone_from(url, path) + } + } + + /// Initialize a new empty repository at `path` with `main` as the initial branch. + pub fn init(path: &Path) -> Result { + std::fs::create_dir_all(path)?; + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts) + .with_context(|| format!("failed to init git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Stage all changes (equivalent to `git add -A`). + pub fn stage_all(&self) -> Result<()> { + let mut index = self.repo.index().context("failed to get git index")?; + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .context("failed to stage changes")?; + index.write().context("failed to write git index")?; + Ok(()) + } + + /// Create a commit. Returns `None` if there are no staged changes. + pub fn commit(&self, message: &str) -> Result> { + self.stage_all()?; + + let mut index = self.repo.index()?; + let tree_id = index.write_tree()?; + + // Check if tree matches current HEAD (nothing to commit) + if let Ok(head) = self.repo.head() { + if let Ok(head_commit) = head.peel_to_commit() { + if head_commit.tree_id() == tree_id { + return Ok(None); + } + } + } + + let tree = self.repo.find_tree(tree_id)?; + let sig = Signature::now("Bread Sync", "bread@localhost")?; + + let oid = match self.repo.head() { + Ok(head) => { + let parent = head.peel_to_commit()?; + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } + Err(_) => { + // First commit — no parents + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + } + }; + + Ok(Some(oid)) + } + + /// Push `branch` to `remote_name`. + pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + let mut push_opts = PushOptions::new(); + let callbacks = make_callbacks(); + push_opts.remote_callbacks(callbacks); + remote + .push(&[refspec.as_str()], Some(&mut push_opts)) + .context("git push failed")?; + Ok(()) + } + + /// Fetch `branch` from `remote_name` without merging. + pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + let mut fetch_opts = make_fetch_options(); + remote + .fetch(&[branch], Some(&mut fetch_opts), None) + .context("git fetch failed")?; + Ok(()) + } + + /// Fetch and fast-forward merge. Errors on non-fast-forward. + pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> { + self.fetch(remote_name, branch)?; + + let fetch_head = self + .repo + .find_reference("FETCH_HEAD") + .context("FETCH_HEAD not found after fetch")?; + let fetch_commit = self + .repo + .reference_to_annotated_commit(&fetch_head) + .context("failed to get annotated commit from FETCH_HEAD")?; + + let (analysis, _) = self + .repo + .merge_analysis(&[&fetch_commit]) + .context("merge analysis failed")?; + + if analysis.is_up_to_date() { + return Ok(()); + } + + if analysis.is_fast_forward() { + let target_id = fetch_commit.id(); + let ref_name = format!("refs/heads/{branch}"); + match self.repo.find_reference(&ref_name) { + Ok(mut r) => { + r.set_target(target_id, "fast-forward pull")?; + } + Err(_) => { + self.repo + .reference(&ref_name, target_id, true, "fast-forward pull")?; + } + } + self.repo.set_head(&ref_name)?; + self.repo + .checkout_head(Some(CheckoutBuilder::default().force())) + .context("checkout failed during pull")?; + Ok(()) + } else { + anyhow::bail!( + "bread: sync conflict — resolve manually in {}", + self.path.display() + ) + } + } + + /// Returns true if working tree has no uncommitted changes. + pub fn is_clean(&self) -> Result { + Ok(self.local_changes()?.is_empty()) + } + + /// Returns list of (status_char, path) for working-tree changes vs HEAD. + pub fn local_changes(&self) -> Result> { + let mut status_opts = StatusOptions::new(); + status_opts + .include_untracked(true) + .recurse_untracked_dirs(true); + + let statuses = self + .repo + .statuses(Some(&mut status_opts)) + .context("failed to get git status")?; + + let mut out = Vec::new(); + for entry in statuses.iter() { + let s = entry.status(); + let ch = if s.contains(git2::Status::INDEX_NEW) + || s.contains(git2::Status::WT_NEW) + { + 'A' + } else if s.contains(git2::Status::INDEX_DELETED) + || s.contains(git2::Status::WT_DELETED) + { + 'D' + } else { + 'M' + }; + if let Some(path) = entry.path() { + out.push((ch, path.to_string())); + } + } + Ok(out) + } + + /// Returns list of (status_char, path) for changes on remote not yet pulled. + pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result> { + // We compare HEAD to remote/branch + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = match self.repo.find_reference(&remote_ref) { + Ok(r) => r.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + let head_commit = match self.repo.head() { + Ok(h) => h.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + if head_commit == remote_oid { + return Ok(vec![]); + } + + let head_tree = self.repo.find_commit(head_commit)?.tree()?; + let remote_tree = self.repo.find_commit(remote_oid)?.tree()?; + + let diff = self + .repo + .diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None) + .context("failed to compute remote diff")?; + + let mut out = Vec::new(); + for delta in diff.deltas() { + let ch = match delta.status() { + git2::Delta::Added => 'A', + git2::Delta::Deleted => 'D', + _ => 'M', + }; + if let Some(path) = delta.new_file().path() { + out.push((ch, path.to_string_lossy().to_string())); + } + } + Ok(out) + } + + /// Return a unified diff string of working tree vs HEAD. + pub fn working_diff(&self) -> Result { + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + + let diff = self + .repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .context("failed to compute working diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format diff")?; + + Ok(out) + } + + /// Return a unified diff string between HEAD and remote branch HEAD. + pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = self + .repo + .find_reference(&remote_ref) + .and_then(|r| r.peel_to_commit()) + .map(|c| c.id()) + .ok(); + + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + let remote_tree = remote_oid + .and_then(|id| self.repo.find_commit(id).ok()) + .and_then(|c| c.tree().ok()); + + let diff = self + .repo + .diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None) + .context("failed to compute remote diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format remote diff")?; + + Ok(out) + } + + /// Set a named remote. + pub fn set_remote(&self, name: &str, url: &str) -> Result<()> { + let _ = self.repo.remote_delete(name); + self.repo + .remote(name, url) + .with_context(|| format!("failed to set remote {name}"))?; + Ok(()) + } + + /// Return the timestamp of the last commit, or None if no commits. + pub fn last_commit_time(&self) -> Option> { + let head = self.repo.head().ok()?; + let commit = head.peel_to_commit().ok()?; + let t = commit.time(); + // git2::Time uses seconds-from-epoch and offset-in-minutes + let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?; + Some(naive.with_timezone(&chrono::Local)) + } +} + +fn make_callbacks<'a>() -> RemoteCallbacks<'a> { + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); + } + Cred::default() + }); + cb +} + +fn make_fetch_options<'a>() -> FetchOptions<'a> { + let mut opts = FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + opts +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..4b89f1a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,9 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..325ef5a --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,79 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Machine profile stored in `machines/.toml` in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineProfile { + pub name: String, + pub hostname: String, + pub tags: Vec, + pub last_sync: String, // RFC 3339 +} + +impl MachineProfile { + /// Create a new profile for this machine. + pub fn new(name: String, tags: Vec) -> Self { + Self { + hostname: hostname(), + name, + tags, + last_sync: Utc::now().to_rfc3339(), + } + } + + /// Write this profile to `/.toml`. + pub fn write(&self, machines_dir: &Path) -> Result<()> { + fs::create_dir_all(machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Read a machine profile from `/.toml`. + pub fn read(machines_dir: &Path, name: &str) -> Result { + let path = machines_dir.join(format!("{name}.toml")); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse machine profile") + } + + /// List all machine profiles in `machines_dir`. + pub fn list(machines_dir: &Path) -> Result> { + if !machines_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(machines_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(profile) = toml::from_str::(&raw) { + out.push(profile); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } +} + +/// Return the system hostname. +pub fn hostname() -> String { + // Try gethostname via libc, fall back to environment variable. + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + return s.to_string(); + } + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..96ad7b3 --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,144 @@ +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Snapshot a package manager's installed packages and write to `dest`. +/// Returns true if the snapshot was written, false if the package manager +/// is not installed (warns instead of failing). +pub fn snapshot(manager: &str, dest: &Path) -> Result { + let content = match manager { + "pacman" => run_pacman()?, + "pip" => run_pip()?, + "npm" => run_npm()?, + "cargo" => run_cargo()?, + other => { + eprintln!("bread: unknown package manager '{}', skipping", other); + return Ok(false); + } + }; + + let Some(content) = content else { + eprintln!( + "bread: package manager '{}' not found, skipping", + manager + ); + return Ok(false); + }; + + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(dest, content)?; + Ok(true) +} + +/// Parse a pacman snapshot (one "name version" per line, space-separated) and +/// return a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +/// Parse a pip freeze snapshot and return package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + l.split("==") + .next() + .unwrap_or(l) + .split(">=") + .next() + .unwrap_or(l) + .trim() + .to_string() + }) + .collect() +} + +/// Parse npm global packages list (parseable format, one path per line). +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + // `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg + let name = Path::new(l) + .file_name() + .map(|n| n.to_string_lossy().to_string())?; + // Skip npm itself and the root node_modules + if name == "node_modules" { + return None; + } + Some(name) + }) + .collect() +} + +/// Parse cargo install list. +/// Format: "crate v1.2.3 (some-path):\n binary\n..." +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .map(|l| { + l.split_whitespace() + .next() + .unwrap_or(l) + .to_string() + }) + .collect() +} + +fn run_pacman() -> Result> { + match Command::new("pacman").arg("-Qe").output() { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pip() -> Result> { + // Try pip3 first, then pip + for cmd in ["pip3", "pip"] { + match Command::new(cmd) + .args(["list", "--user", "--format=freeze"]) + .output() + { + Ok(out) if out.status.success() => { + return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } + } + Ok(None) +} + +fn run_npm() -> Result> { + match Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_cargo() -> Result> { + match Command::new("cargo").args(["install", "--list"]).output() { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..484120c --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,257 @@ +use bread_sync::{ + config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, + delegates, machine, packages, SyncRepo, +}; +use std::fs; +use tempfile::TempDir; + +fn make_bare_repo(path: &std::path::Path) -> git2::Repository { + let mut opts = git2::RepositoryInitOptions::new(); + opts.bare(true); + opts.initial_head("main"); + git2::Repository::init_opts(path, &opts).unwrap() +} + +// Helper to create a git commit in a non-bare repo so we have initial state +fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { + let repo = SyncRepo::init(path).unwrap(); + fs::write(path.join(".gitkeep"), "").unwrap(); + repo.stage_all().unwrap(); + repo.commit("initial commit").unwrap(); + repo +} + +#[test] +fn sync_init_creates_toml_with_required_fields() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "testbox".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); + assert_eq!(loaded.remote.branch, "main"); + assert_eq!(loaded.machine.name, "testbox"); + assert_eq!(loaded.machine.tags, vec!["mobile"]); +} + +#[test] +fn sync_init_errors_if_already_initialized() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "box".to_string(), + tags: vec![], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + // Second load should succeed (init itself must check for existence externally) + // We test that load works + let result = SyncConfig::load(tmp.path()); + assert!(result.is_ok()); + // sync.toml now exists — the CLI checks this before calling save + assert!(tmp.path().join("sync.toml").exists()); +} + +#[test] +fn sync_push_creates_correct_directory_structure() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + // Create initial bare remote + let _bare = make_bare_repo(bare_tmp.path()); + + // Create local bread config + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); + + // Init local sync repo + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + // Snapshot bread dir + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + // Write machine profile + let machines_dir = repo_tmp.path().join("machines"); + let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); + profile.write(&machines_dir).unwrap(); + + // Commit and push + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify structure in local repo + assert!(repo_tmp.path().join("bread").exists()); + assert!(repo_tmp.path().join("bread").join("init.lua").exists()); + assert!(repo_tmp.path().join("machines").join("testbox.toml").exists()); +} + +#[test] +fn sync_push_snapshots_bread_config() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a more complex bread config + fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); + fs::write( + bread_cfg_tmp.path().join("modules/mymod/init.lua"), + "-- mymod", + ) + .unwrap(); + + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify files were copied + assert!(bread_dest.join("init.lua").exists()); + assert!(bread_dest.join("modules/mymod/init.lua").exists()); + + let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); + assert_eq!(content, "-- init"); +} + +#[test] +fn sync_pull_copies_files_from_repo() { + let bare_tmp = TempDir::new().unwrap(); + let local_tmp = TempDir::new().unwrap(); + let apply_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a local repo, add some files, push to bare + let repo = SyncRepo::init(local_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + let bread_dest = local_tmp.path().join("bread"); + fs::create_dir_all(&bread_dest).unwrap(); + fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); + + repo.commit("sync: first push").unwrap(); + repo.push("origin", "main").unwrap(); + + // Now clone the bare repo and pull + let clone_tmp = TempDir::new().unwrap(); + let cloned = SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); + + // Apply bread/ to apply_tmp + let src = clone_tmp.path().join("bread"); + if src.exists() { + delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); + } + + assert!(apply_tmp.path().join("init.lua").exists()); + let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); + assert_eq!(content, "-- from sync"); +} + +#[test] +fn package_manifest_pacman_parses_output_correctly() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + let pkgs = packages::parse_pacman(input); + assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); +} + +#[test] +fn package_manifest_pip_parses_output_correctly() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + let pkgs = packages::parse_pip(input); + assert_eq!(pkgs, vec!["requests", "numpy", "black"]); +} + +#[test] +fn delegates_exclude_globs_filter_correctly() { + let src_tmp = TempDir::new().unwrap(); + let dst_tmp = TempDir::new().unwrap(); + + // Create files that should and shouldn't be copied + fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); + fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); + fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); + fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); + fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); + + let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; + delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); + + assert!(dst_tmp.path().join("lua/init.lua").exists()); + assert!(!dst_tmp.path().join(".git").exists()); + assert!(!dst_tmp.path().join("log.cache").exists()); +} + +#[test] +fn machine_profile_written_with_correct_fields() { + let machines_tmp = TempDir::new().unwrap(); + let profile = machine::MachineProfile::new( + "myhost".to_string(), + vec!["mobile".to_string(), "battery".to_string()], + ); + profile.write(machines_tmp.path()).unwrap(); + + let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); + assert_eq!(loaded.name, "myhost"); + assert_eq!(loaded.tags, vec!["mobile", "battery"]); + assert!(!loaded.hostname.is_empty()); + // last_sync must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); + assert!( + parsed.is_ok(), + "last_sync '{}' is not valid RFC 3339", + loaded.last_sync + ); +} + +#[test] +fn status_shows_no_changes_when_clean() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + let changes = repo.local_changes().unwrap(); + assert!( + changes.is_empty(), + "expected no local changes, got: {:?}", + changes + ); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn push_with_no_changes_returns_none() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + + // No new changes — commit should return None + let result = repo.commit("second commit").unwrap(); + assert!( + result.is_none(), + "expected None (nothing to commit), got: {:?}", + result + ); +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index 6c5cea1..c3aba56 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,7 +52,13 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - + match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { + Ok(()) => return Ok(()), + Err(err) => { + tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); + } + } + // Fallback: poll sysfs every 2 seconds for environments where the // netlink socket is unavailable (missing plugdev membership, containers, etc). let mut known: HashMap = scan_devices(&self.subsystems) @@ -95,6 +101,71 @@ struct ScannedDevice { id: String, name: String, subsystem: String, + vendor_id: Option, + product_id: Option, +} + +async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + let mut builder = udev::MonitorBuilder::new()?; + for subsystem in &subsystems { + builder = builder.match_subsystem(subsystem)?; + } + let monitor = builder.listen()?; + + for event in monitor.iter() { + let action = event + .action() + .map(|a| a.to_string_lossy().to_string()) + .unwrap_or_else(|| "change".to_string()); + let subsystem = event + .subsystem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let name = event + .property_value("ID_MODEL") + .or_else(|| event.property_value("NAME")) + .map(|v| v.to_string_lossy().to_string()) + .or_else(|| event.devnode().map(|n| n.display().to_string())) + .unwrap_or_else(|| "unknown".to_string()); + let id = event + .syspath() + .to_string_lossy() + .to_string(); + + let msg = RawEvent { + source: AdapterSource::Udev, + kind: "udev.change".to_string(), + payload: json!({ + "action": action, + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"), + "id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"), + "id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"), + "id_usb_class": prop_str(&event, "ID_USB_CLASS"), + "id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"), + "id_vendor": prop_str(&event, "ID_VENDOR"), + "id_model": prop_str(&event, "ID_MODEL"), + "vendor_id": prop_str(&event, "ID_VENDOR_ID"), + "product_id": prop_str(&event, "ID_MODEL_ID"), + }), + timestamp: now_unix_ms(), + }; + + if tx.blocking_send(msg).is_err() { + break; + } + } + + Ok(()) + }) + .await??; + + Ok(()) } fn enumerate_with_udev(subsystems: &[String]) -> Result> { @@ -116,11 +187,19 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .or_else(|| dev.sysname().to_str().map(ToString::to_string)) .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); + let vendor_id = dev + .property_value("ID_VENDOR_ID") + .map(|v| v.to_string_lossy().to_string()); + let product_id = dev + .property_value("ID_MODEL_ID") + .map(|v| v.to_string_lossy().to_string()); out.push(ScannedDevice { id, name, subsystem, + vendor_id, + product_id, }); } @@ -136,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { "id": dev.id, "name": dev.name, "subsystem": dev.subsystem, + "vendor_id": dev.vendor_id, + "product_id": dev.product_id, }), timestamp: now_unix_ms(), } @@ -159,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("drm:{name}"), name, subsystem: "drm".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -175,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("input:{name}"), name, subsystem: "input".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -190,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("power_supply:{name}"), name, subsystem: "power_supply".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -202,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result> { let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { + let syspath = entry.path(); + let vendor_id = fs::read_to_string(syspath.join("idVendor")) + .ok() + .map(|s| s.trim().to_string()); + let product_id = fs::read_to_string(syspath.join("idProduct")) + .ok() + .map(|s| s.trim().to_string()); out.push(ScannedDevice { id: format!("usb:{name}"), name, subsystem: "usb".to_string(), + vendor_id, + product_id, }); } } diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index caea8dc..6e69e6a 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -36,6 +36,7 @@ pub enum StateCommand { id: SubscriptionId, }, ClearSubscriptions, + ClearModules, SetModuleStatus { name: String, status: ModuleLoadState, @@ -112,6 +113,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + pub fn set_module_status( &self, name: String, @@ -236,6 +241,9 @@ async fn handle_command( watches.clear(); subscription_count.store(0, Ordering::Relaxed); } + StateCommand::ClearModules => { + state.write().await.modules.clear(); + } StateCommand::SetModuleStatus { name, status, @@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), + vendor_id: data + .get("vendor_id") + .and_then(Value::as_str) + .map(ToString::to_string), + product_id: data + .get("product_id") + .and_then(Value::as_str) + .map(ToString::to_string), }); } else { state.devices.connected.retain(|d| d.id != id); diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 45ccfa5..119b7af 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -57,6 +57,10 @@ pub struct Device { pub name: String, pub class: DeviceClass, pub subsystem: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub vendor_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index fff3368..25fe66c 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,6 +267,39 @@ impl Server { "recent_errors": recent_errors, })) } + "sync.status" => { + let cfg_home = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| { + std::env::var("HOME") + .map(|h| std::path::PathBuf::from(h).join(".config")) + }) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let sync_path = cfg_home.join("bread").join("sync.toml"); + match std::fs::read_to_string(&sync_path) + .ok() + .and_then(|s| s.parse::().ok()) + { + Some(toml) => { + let machine = toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let remote = toml + .get("remote") + .and_then(|r| r.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Ok(json!({ + "initialized": true, + "machine": machine, + "remote": remote, + })) + } + None => Ok(json!({ "initialized": false })), + } + } "events.replay" => { let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); let cutoff = now_unix_ms().saturating_sub(since_ms); diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 228ac61..7caa9c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; +use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -250,6 +251,7 @@ impl LuaEngine { self.run_on_unload(); self.cancel_all_timers(); self.state_handle.clear_subscriptions(); + self.state_handle.clear_modules(); self.lua = Lua::new(); self.handlers .lock() @@ -837,6 +839,66 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; + // bread.machine — machine name and tags from sync.toml + let machine_tbl = self.lua.create_table()?; + + let name_fn = self.lua.create_function(|_lua, ()| { + Ok(lua_machine_name()) + })?; + machine_tbl.set("name", name_fn)?; + + let tags_fn = self.lua.create_function(|lua, ()| { + let tags = lua_machine_tags(); + let tbl = lua.create_table()?; + for (i, tag) in tags.iter().enumerate() { + tbl.set(i + 1, tag.clone())?; + } + Ok(tbl) + })?; + machine_tbl.set("tags", tags_fn)?; + + let has_tag_fn = self.lua.create_function(|_lua, tag: String| { + Ok(lua_machine_tags().contains(&tag)) + })?; + machine_tbl.set("has_tag", has_tag_fn)?; + + bread.set("machine", machine_tbl)?; + + // bread.fs — file system helpers + let fs_tbl = self.lua.create_table()?; + + let write_fn = self.lua.create_function(|_lua, (path, content): (String, String)| { + let expanded = lua_expand_path(&path); + if let Some(parent) = expanded.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LuaError::external(e.to_string()))?; + } + std::fs::write(&expanded, content) + .map_err(|e| LuaError::external(e.to_string())) + })?; + fs_tbl.set("write", write_fn)?; + + let read_fn = self.lua.create_function(|_lua, path: String| { + let expanded = lua_expand_path(&path); + match std::fs::read_to_string(&expanded) { + Ok(s) => Ok(Some(s)), + Err(_) => Ok(None), + } + })?; + fs_tbl.set("read", read_fn)?; + + let exists_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).exists()) + })?; + fs_tbl.set("exists", exists_fn)?; + + let expand_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).to_string_lossy().to_string()) + })?; + fs_tbl.set("expand", expand_fn)?; + + bread.set("fs", fs_tbl)?; + globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; @@ -927,7 +989,7 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = if let Some(source) = decl.source.as_deref() { + let result = if let Some(source) = decl.source { self.load_lua_source(source, &decl.name) } else { self.load_lua_file(&decl.path, &decl.name, decl.builtin) @@ -1296,16 +1358,31 @@ impl LuaEngine { Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) })?; + // Build a minimal bread stub: bread.module() captures the decl and aborts; + // all other bread.* accesses return a no-op callable so modules that call + // bread.log() or bread.fs.exists() before bread.module() don't crash during scanning. let bread = lua.create_table()?; bread.set("module", module_fn)?; lua.globals().set("bread", bread)?; + lua.load(r#" + local _noop = function(...) end + local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop } + local _noop_tbl = setmetatable({}, _noop_tbl_mt) + setmetatable(bread, { + __index = function(_, k) + if k == "module" then return rawget(bread, "module") end + return _noop_tbl + end + }) + "#).exec()?; let src = fs::read_to_string(path)?; let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + // bread.module() throws MODULE_DECL_ABORT to abort scanning early. + // mlua may wrap the error in CallbackError, so match on string content. if let Err(err) = result { - match err { - LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} - other => return Err(anyhow!(other.to_string())), + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); } } @@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St }); } +fn lua_expand_path(path: &str) -> std::path::PathBuf { + if path == "~" { + if let Some(home) = dirs_home() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs_home() { + return home.join(rest); + } + } + std::path::PathBuf::from(path) +} + +fn dirs_home() -> Option { + if let Ok(home) = std::env::var("HOME") { + return Some(std::path::PathBuf::from(home)); + } + None +} + +fn lua_machine_name() -> String { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(name) = sync_toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + { + return name.to_string(); + } + } + lua_hostname() +} + +fn lua_hostname() -> String { + // Try gethostname via libc + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + if !s.is_empty() { + return s.to_string(); + } + } + } + } + // Fall back to /etc/hostname + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let trimmed = h.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn lua_machine_tags() -> Vec { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(tags) = sync_toml + .get("machine") + .and_then(|m| m.get("tags")) + .and_then(|v| v.as_array()) + { + return tags + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + } + } + vec![] +} + +fn read_sync_toml() -> anyhow::Result { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| { + std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config")) + }) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let path = config_dir.join("bread").join("sync.toml"); + let raw = std::fs::read_to_string(path)?; + Ok(raw.parse::()?) +} + const BUILTIN_MONITORS: &str = r#" local M = bread.module({ name = "bread.monitors", version = "1.0.0" }) diff --git a/packaging/README.md b/packaging/README.md index 3f829f8..18256de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,5 +1,47 @@ -Packaging notes -================ +Packaging +========= -This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under -`packaging/arch/`. +This directory contains distribution packaging for Bread. + +``` +packaging/ +├── arch/ +│ └── PKGBUILD ← Arch Linux package build script +└── systemd/ + └── breadd.service ← systemd user service unit +``` + +## Arch Linux + +```bash +cd packaging/arch +makepkg -si +``` + +The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`. + +Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball. + +## systemd user service + +The service unit starts `breadd` as a user service after the graphical session is available. + +```bash +# Install and enable manually (if not using the PKGBUILD) +mkdir -p ~/.config/systemd/user +cp systemd/breadd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadd + +# Check status +systemctl --user status breadd +journalctl --user -u breadd -f +``` + +The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in: + +```ini +# ~/.config/systemd/user/breadd.service.d/debug.conf +[Service] +Environment=RUST_LOG=debug +``` diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 1873cd6..020e26c 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,9 +1,29 @@ Arch packaging ============== -This is a minimal PKGBUILD skeleton. +`PKGBUILD` builds and installs both `breadd` and `bread` from source. -Steps to use: -- Update `pkgver`, `source`, `sha256sums`, and `url`. -- Set the correct license and dependencies. -- Ensure the release tarball includes `packaging/systemd/breadd.service`. +## Local build + +```bash +makepkg -si +``` + +## Before publishing to AUR + +1. Tag a release on GitHub. +2. Update `pkgver` to match the tag. +3. Update `source` to the release tarball URL. +4. Run `updpkgsums` (or manually set `sha256sums`). +5. Update `url` if the repository has moved. +6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically. + +## Runtime dependencies + +| Package | Required | Notes | +|---------|----------|-------| +| `glibc` | yes | always | +| `udev` | yes | device events | +| `dbus` | optional | UPower battery events | +| `libnotify` | optional | `bread.notify()` (uses `notify-send`) | +| `git` | optional | `bread sync` push/pull |