From cc456b78fe658aec76f65981b91c91aa9125c633 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 00:22:21 +0800 Subject: [PATCH 01/21] refactor: remove remote module install, extract bread-sync, make CI real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Remove `bread modules install github:…`. Remote fetch pulled unreviewed third-party Lua and ran it with full bread.exec() privileges in an unsandboxed runtime. Module install is now local-only; parse_source rejects github:/git: with an explicit message. bread-sync extracted from the workspace (parked for its own project): - Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md - Removed the entire `bread sync` CLI surface and now-unused deps (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency) - Removed the sync.status IPC method from breadd plus its integration tests - Moved the generic `expand_path` helper into bread-shared (with unit tests) CI now actually runs and gates quality: - Trigger on master/dev (was `main` — CI had never run, not once) - Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings - Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink); added the libudev-dev system dependency the Linux build needs Hardening / honesty: - New ipc test: daemon survives repeated reloads and the event pipeline resumes (the prior suite only had a single happy-path reload check) - Docs scrubbed of sync across README/Documentation/Overview/DAEMON - "production-ready" and "compositor-agnostic" claims reworded to match reality rather than aspiration Note: bread-sync/src/export.rs held pre-existing local WIP authored outside this change set and is intentionally excluded from this commit. --- .github/workflows/ci.yml | 24 +- Cargo.lock | 878 +------------------------------- Cargo.toml | 4 + Documentation.md | 122 +---- README.md | 104 +--- bread-cli/Cargo.toml | 6 +- bread-cli/src/main.rs | 747 +-------------------------- bread-cli/src/modules_mgmt.rs | 51 +- bread-shared/src/lib.rs | 42 ++ bread-sync/EXTRACTION.md | 36 ++ breadd/Cargo.toml | 1 - breadd/src/ipc/mod.rs | 26 - breadd/src/lua/mod.rs | 22 +- breadd/tests/ipc_integration.rs | 85 ++-- 14 files changed, 202 insertions(+), 1946 deletions(-) create mode 100644 bread-sync/EXTRACTION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7409b04..ce7614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,29 +2,31 @@ name: CI on: push: - branches: [ main ] + branches: [ master, dev ] pull_request: - branches: [ main ] + branches: [ master, dev ] jobs: build-and-test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - rust: [stable] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@v1 with: - toolchain: ${{ matrix.rust }} + toolchain: stable + components: clippy, rustfmt + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config - name: Cargo cache uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target + - name: Format check + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings - name: Build run: cargo build --workspace --verbose - name: Run tests @@ -34,9 +36,9 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: bread-${{ matrix.os }} + name: bread-ubuntu-latest path: dist/*.tgz diff --git a/Cargo.lock b/Cargo.lock index 52ab50b..155c062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # 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" @@ -263,12 +257,6 @@ 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" @@ -309,17 +297,13 @@ version = "1.0.0" dependencies = [ "anyhow", "bread-shared", - "bread-sync", "chrono", "clap", "dirs", - "flate2", "libc", "notify", - "reqwest", "serde", "serde_json", - "tar", "tempfile", "tokio", "toml", @@ -333,22 +317,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-sync" -version = "1.0.0" -dependencies = [ - "anyhow", - "chrono", - "dirs", - "git2", - "glob", - "libc", - "serde", - "serde_json", - "tempfile", - "toml", -] - [[package]] name = "breadd" version = "1.0.0" @@ -356,7 +324,6 @@ dependencies = [ "anyhow", "async-trait", "bread-shared", - "bread-sync", "futures-util", "libc", "mlua", @@ -409,8 +376,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -489,26 +454,6 @@ 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" @@ -524,15 +469,6 @@ 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" @@ -600,32 +536,12 @@ dependencies = [ "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" @@ -749,52 +665,12 @@ 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" @@ -941,18 +817,6 @@ 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" @@ -961,51 +825,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "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" @@ -1045,77 +869,6 @@ 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" @@ -1140,115 +893,12 @@ 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" @@ -1301,12 +951,6 @@ 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" @@ -1319,16 +963,6 @@ 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" @@ -1379,20 +1013,6 @@ 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" @@ -1402,20 +1022,6 @@ 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" @@ -1426,18 +1032,6 @@ 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" @@ -1456,12 +1050,6 @@ 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" @@ -1538,22 +1126,6 @@ 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" @@ -1607,23 +1179,6 @@ 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" @@ -1763,55 +1318,6 @@ 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" @@ -1872,12 +1378,6 @@ 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" @@ -1931,15 +1431,6 @@ 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" @@ -1987,12 +1478,6 @@ 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" @@ -2078,46 +1563,6 @@ 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" @@ -2179,27 +1624,12 @@ 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" @@ -2209,44 +1639,12 @@ 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" @@ -2326,18 +1724,6 @@ 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" @@ -2374,12 +1760,6 @@ 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" @@ -2412,12 +1792,6 @@ 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" @@ -2452,55 +1826,6 @@ 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" @@ -2543,16 +1868,6 @@ 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" @@ -2582,29 +1897,6 @@ 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" @@ -2657,12 +1949,6 @@ 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" @@ -2724,12 +2010,6 @@ 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" @@ -2777,24 +2057,6 @@ 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" @@ -2807,12 +2069,6 @@ 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" @@ -2835,15 +2091,6 @@ 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" @@ -2881,16 +2128,6 @@ dependencies = [ "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" @@ -2957,16 +2194,6 @@ 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" @@ -3235,16 +2462,6 @@ 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" @@ -3345,22 +2562,6 @@ 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" @@ -3371,29 +2572,6 @@ 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" @@ -3481,60 +2659,6 @@ 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 8216be1..7d0c7a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ members = [ "bread-shared", "breadd", "bread-cli", +] +# bread-sync is being extracted into its own project (see bread-sync/EXTRACTION.md). +# Excluded so it no longer builds, tests, or gates CI as part of bread. +exclude = [ "bread-sync", ] resolver = "2" diff --git a/Documentation.md b/Documentation.md index 36c6d73..a06699f 100644 --- a/Documentation.md +++ b/Documentation.md @@ -7,7 +7,6 @@ - [Your first module](#your-first-module) - [Run, reload, and watch](#run-reload-and-watch) - [Modules: install and manage](#modules-install-and-manage) -- [Sync: snapshot and restore](#sync-snapshot-and-restore) - [Debugging tips](#debugging-tips) - [Dictionary: Lua API](#dictionary-lua-api) - [Bluetooth](#bluetooth) @@ -101,15 +100,16 @@ If any module fails to load, `bread reload` prints the error with a full Lua sta Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle. +Modules install from a **local directory only**. They run with full +`bread.exec()` privileges and are not sandboxed; remote installation was +removed so that reviewing third-party code stays an explicit, manual step. To +use a module published on a git host, clone it yourself, review it, then +install from the checkout. + ```bash -# Install from GitHub (downloads and extracts the default branch tarball) -bread modules install github:someuser/bread-wifi - -# Install from a local directory -bread modules install ~/src/my-module - -# Install a specific ref -bread modules install github:someuser/bread-wifi@v1.2.0 +# Clone and review, then install from the local checkout +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +bread modules install ~/src/bread-wifi # List installed modules and their daemon status bread modules list @@ -117,9 +117,6 @@ bread modules list # Show full manifest for one module bread modules info bread-wifi -# Re-install all GitHub-sourced modules (pick up upstream changes) -bread modules update - # Remove a module bread modules remove bread-wifi bread modules remove bread-wifi --yes # skip confirmation @@ -132,101 +129,10 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/bread-wifi" installed_at = "2026-01-01T00:00:00Z" ``` -## Sync: snapshot and restore - -Bread sync snapshots your config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required. - -```bash -# First-time setup (remote optional) -bread sync init -bread sync init --remote git@github.com:you/bread-config.git - -# Commit local snapshot -bread sync push -bread sync push --message "before reinstall" - -# Apply snapshot to this machine -bread sync pull - -# Also reinstall packages from snapshot -bread sync pull --install-packages - -# See what has changed -bread sync status -bread sync diff - -# List known machines -bread sync machines -``` - -### Portable export/import - -`export` creates a self-contained snapshot directory or `.tar.gz` — no git auth needed. - -```bash -# Create a portable snapshot (defaults to ./bread-export--.tar.gz) -bread sync export - -# Export to a specific path -bread sync export --output ~/backups/bread.tar.gz -bread sync export --output /mnt/usb/bread-snapshot/ # directory - -# Apply a snapshot on another machine -bread sync import bread-export-hermes-2026-05-16.tar.gz -bread sync import /mnt/usb/bread-snapshot/ - -# Also install packages from the snapshot -bread sync import bread-export.tar.gz --install-packages - -# Skip cloning git repos back to their original locations -bread sync import bread-export.tar.gz --no-clone-repos - -# Skip confirmation prompt -bread sync import bread-export.tar.gz --yes -``` - -Each export snapshot includes: - -| Directory | Contents | -|-----------|----------| -| `bread/` | `~/.config/bread/` (your Bread config) | -| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) | -| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. | -| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) | -| `local-fonts/` | `~/.local/share/fonts/` | -| `systemd/` | `~/.config/systemd/user/` units | -| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) | -| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) | -| `machines/` | Per-machine profile with tags and last-sync time | -| `manifest.toml` | Path map for exact restoration on import | -| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) | - -**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back. - -Configure sync 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"] -``` - ## Debugging tips - Run `bread events` to see live normalized events. @@ -396,10 +302,13 @@ Logging helpers. Accept any Lua value (coerced via `tostring`). ### Machine and filesystem #### `bread.machine.name() -> string` -Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized. +Returns the system hostname. If an external tool has written a +`~/.config/bread/sync.toml` with a `[machine].name`, that value takes +precedence (bread reads the file if present but does not create it). #### `bread.machine.tags() -> string[]` -Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. +Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file +exists, otherwise `{}`. #### `bread.machine.has_tag(tag) -> bool` Returns true if the machine has the given tag. @@ -924,4 +833,3 @@ Available methods: | `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line | | `events.replay` | `since_ms` | Replay buffered events from the last N ms | | `emit` | `event`, `data` | Inject a synthetic event into the pipeline | -| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` | diff --git a/README.md b/README.md index 18ad5ba..0fab7e2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ return M 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 ``` @@ -194,26 +193,9 @@ bread profile-activate # Activate a named profile # 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 install /local/path # Install from a local module 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 (remote optional) -bread sync push # Commit local snapshot -bread sync push --message "note" # Commit with a custom message -bread sync pull # Apply local snapshot to this machine -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 machines # List known machines from sync repo -bread sync export # Create a portable .tar.gz snapshot (no git auth) -bread sync export --output path # Export to a specific file or directory -bread sync import # Apply a portable snapshot (.tar.gz or directory) -bread sync import --install-packages # Also install packages -bread sync import --no-clone-repos # Skip cloning git repos ``` --- @@ -224,15 +206,15 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. ### Installing modules +Modules install from a local directory only. Modules run with full +`bread.exec()` privileges and are **not** sandboxed, so to use a module +published on a git host, clone it yourself and review the Lua before +installing from the local checkout: + ```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 +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +# review ~/src/bread-wifi, then: +bread modules install ~/src/bread-wifi ``` ### Writing a module @@ -252,7 +234,7 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/bread-wifi" installed_at = "2026-01-01T00:00:00Z" ``` @@ -269,67 +251,6 @@ return M --- -## Sync system - -Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote. - -```bash -# First-time setup (remote is optional) -bread sync init -bread sync init --remote git@github.com:you/bread-config.git - -# Commit a local snapshot -bread sync push - -# Create a portable .tar.gz (no git auth required) -bread sync export - -# On another machine: apply the snapshot -bread sync import bread-export-hermes-2026-05-16.tar.gz - -# Also install packages on import -bread sync import bread-export.tar.gz --install-packages -``` - -Configure what gets synced in `~/.config/bread/sync.toml`: - -```toml -[remote] -url = "git@github.com:you/bread-config.git" # optional -branch = "main" - -[machine] -name = "hermes" -tags = ["laptop", "battery"] - -[packages] -enabled = true -managers = ["pacman", "pip", "cargo"] - -[delegates] -include = ["~/.config/nvim", "~/.config/waybar"] -exclude = ["**/.git", "**/*.cache"] -``` - -A portable export snapshot contains: - -``` -bread-export-hermes-2026-05-16/ -├── bread/ ← ~/.config/bread/ -├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, … -├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, … -├── local-bin/ ← ~/.local/bin/ scripts -├── local-fonts/ ← ~/.local/share/fonts/ -├── systemd/ ← ~/.config/systemd/user/ units -├── system/ ← udev rules, modprobe, sysctl (sudo required for some) -├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt -├── machines/ ← per-machine profiles -├── manifest.toml ← path map for exact restore -└── restore.sh ← shell script for manual restore -``` - ---- - ## Event reference Events follow the namespace convention `bread...`. @@ -496,7 +417,7 @@ end ### Machine and filesystem ```lua --- Machine identity (from sync.toml, falls back to hostname) +-- Machine identity (system hostname) local name = bread.machine.name() local tags = bread.machine.tags() -- array of strings local ok = bread.machine.has_tag("laptop") @@ -616,7 +537,6 @@ Available methods: | `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. @@ -626,7 +546,7 @@ Available methods: Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API and module system. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 1e4b667..6688aea 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -13,7 +13,6 @@ 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 @@ -24,7 +23,6 @@ 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" + +[dev-dependencies] tempfile.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 924c7b3..64d44a0 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,10 +1,6 @@ mod modules_mgmt; -use anyhow::{Context, Result}; -use bread_sync::{ - config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, apply_import, stage_export, SyncRepo, -}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; @@ -62,11 +58,6 @@ enum Commands { #[command(subcommand)] subcommand: ModulesCommand, }, - /// Manage sync (snapshot and restore system state) - Sync { - #[command(subcommand)] - subcommand: SyncCommand, - }, /// List available profiles ProfileList, /// Activate a profile @@ -91,9 +82,9 @@ enum Commands { #[derive(Subcommand, Debug)] enum ModulesCommand { - /// Install a module from a source + /// Install a module from a local directory Install { - /// Source: github:user/repo[@ref] or /path/to/dir + /// Path to a local module directory source: String, }, /// Remove an installed module @@ -105,66 +96,10 @@ enum ModulesCommand { }, /// 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, - /// Create a portable export archive (no git auth required) - Export { - /// Output path: directory or .tar.gz file. Defaults to ./bread-export--.tar.gz - #[arg(long, short)] - output: Option, - }, - /// Apply a portable export archive to this machine - Import { - /// Path to a bread export directory or .tar.gz file - from: PathBuf, - /// Also install packages from the package manifests - #[arg(long)] - install_packages: bool, - /// Skip cloning git repositories to their original locations - #[arg(long)] - no_clone_repos: bool, - /// Skip confirmation prompt - #[arg(long)] - yes: bool, - }, -} - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -202,9 +137,6 @@ async fn main() -> Result<()> { 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)?; @@ -257,7 +189,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = install_module(&source, &mods_dir).await?; + let manifest = install_module(&source, &mods_dir)?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -312,39 +244,6 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { } } - 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 { @@ -371,74 +270,12 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { Ok(()) } -async fn install_module( +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) + let path = modules_mgmt::parse_source(source)?; + modules_mgmt::install_from_local(&path, source, mods_dir) } /// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. @@ -451,576 +288,6 @@ async fn try_daemon_reload(socket: &Path) { } } -// --------------------------------------------------------------------------- -// 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?, - SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?, - SyncCommand::Import { from, install_packages, no_clone_repos, yes } => { - cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).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 (leave empty for local-only, e.g. git@github.com:you/config): "); - 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)?; - - println!(); - println!("sync initialized"); - println!(" machine: {}", machine_name); - if remote_url.is_empty() { - println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)"); - } else { - println!(" remote: {}", remote_url); - if !remote_url.starts_with('/') && !remote_url.starts_with('.') { - println!(" note: remote will be created on first push"); - } - } - 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(); - - let repo = if repo_path.exists() { - SyncRepo::open(&repo_path)? - } else { - SyncRepo::init(&repo_path)? - }; - - // Snapshot bread/ directory - let bread_dest = repo_path.join("bread"); - delegates::sync_dir(cfg_dir, &bread_dest, &[".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 {manager} failed: {e}"); - } - } - } - - // Write machine profile - let machines_dir = repo_path.join("machines"); - machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) - .write(&machines_dir)?; - - 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 commit — already up to date"); - return Ok(()); - } - - println!("committed sync for {}", config.machine.name); - println!(" snapshot: {}", repo_path.display()); - println!(" tip: run 'bread sync export' to create a portable snapshot"); - 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(); - - if !repo_path.exists() { - eprintln!("bread: no local snapshot found. Run 'bread sync push' first."); - 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 committed — run 'bread sync push'"); - return Ok(()); - } - - let repo = SyncRepo::open(&repo_path)?; - - let last_commit = 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!(" snapshot {}", repo_path.display()); - println!(" last commit {}", last_commit); - - let local_changes = repo.local_changes()?; - println!(); - println!("uncommitted changes:"); - if local_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &local_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 = 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(()) -} - -async fn cmd_sync_export(cfg_dir: &Path, output: Option) -> Result<()> { - // Load sync config if available; fall back to machine defaults. - let config = match SyncConfig::load(cfg_dir) { - Ok(c) => c, - Err(_) => { - let name = machine::hostname(); - SyncConfig { - remote: bread_sync::config::RemoteConfig { - url: String::new(), - branch: "main".to_string(), - }, - machine: bread_sync::config::MachineConfig { name, tags: vec![] }, - packages: bread_sync::config::PackagesConfig::default(), - delegates: bread_sync::config::DelegatesConfig::default(), - } - } - }; - - let date = chrono::Utc::now().format("%Y-%m-%d"); - let export_name = format!("bread-export-{}-{}", config.machine.name, date); - - // Decide: tarball or directory? - let (staging_path, make_tarball, final_path) = match &output { - Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => { - // User wants a .tar.gz at a specific path - let staging = std::env::temp_dir().join(&export_name); - (staging, true, p.clone()) - } - Some(p) if p.is_dir() || !p.exists() => { - // User wants a directory - let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() }; - (dir.clone(), false, dir) - } - Some(p) => { - anyhow::bail!("output path {} already exists and is not a directory", p.display()); - } - None => { - // Default: .tar.gz in current directory - let tarball = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(format!("{export_name}.tar.gz")); - let staging = std::env::temp_dir().join(&export_name); - (staging, true, tarball) - } - }; - - // Stage everything into the staging directory - let manifest = stage_export(cfg_dir, &config, &staging_path) - .context("failed to stage export")?; - - // Optionally pack into a tarball - if make_tarball { - create_tarball(&staging_path, &final_path) - .context("failed to create tarball")?; - std::fs::remove_dir_all(&staging_path).ok(); - } - - println!("exported to {}", final_path.display()); - println!(" machine: {}", manifest.machine); - if !manifest.configs.is_empty() { - println!(" configs: {}", manifest.configs.join(", ")); - } - if !manifest.path_map.is_empty() { - let file_count = manifest.path_map.iter().filter(|r| r.is_file).count(); - let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count(); - if file_count > 0 { - println!(" dotfiles: {} file(s)", file_count); - } - if dir_count > manifest.configs.len() { - println!(" dirs: {} total", dir_count); - } - } - if !manifest.packages.is_empty() { - println!(" packages: {}", manifest.packages.join(", ")); - } - if !manifest.repos.is_empty() { - println!(" repos: {} git repositories tracked", manifest.repos.len()); - } - if manifest.system { - println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)"); - } - Ok(()) -} - -async fn cmd_sync_import( - cfg_dir: &Path, - from: PathBuf, - install_packages: bool, - clone_repos: bool, - yes: bool, - socket: &Path, -) -> Result<()> { - // Determine staging directory - let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz"); - - let (staging, _tmp_guard) = if is_tarball { - let tmp = tempfile::tempdir().context("failed to create temp dir")?; - extract_tarball(&from, tmp.path()).context("failed to extract tarball")?; - // GitHub-style tarballs extract into a single subdirectory; unwrap if needed - let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf()); - (inner, Some(tmp)) - } else if from.is_dir() { - (from.clone(), None) - } else { - anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display()); - }; - - // Read manifest for summary - let manifest_path = staging.join("manifest.toml"); - if !manifest_path.exists() { - anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display()); - } - let manifest_raw = std::fs::read_to_string(&manifest_path)?; - let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw) - .context("failed to parse manifest.toml")?; - - println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]); - println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") }); - println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") }); - if !manifest.repos.is_empty() { - println!(" repos: {} git repositories found", manifest.repos.len()); - if clone_repos { - println!(" (will be cloned to their original locations)"); - } else { - println!(" (skipping clone — remove --no-clone-repos to restore)"); - } - } - if manifest.system { - println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically"); - } - - if !yes { - print!("\nApply to ~/.config and ~/.local? (y/n): "); - 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(()); - } - } - - let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos) - .context("import failed")?; - - println!(); - for item in &applied { - println!(" + {item}"); - } - - if manifest.system { - println!(); - println!("system files were NOT applied automatically. To restore them:"); - println!(" {}/restore.sh", staging.display()); - } - - // Notify daemon - try_daemon_reload(socket).await; - - Ok(()) -} - -fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> { - use flate2::{write::GzEncoder, Compression}; - - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - let file = std::fs::File::create(dest) - .with_context(|| format!("failed to create {}", dest.display()))?; - let encoder = GzEncoder::new(file, Compression::default()); - let mut archive = tar::Builder::new(encoder); - - let base_name = src_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("bread-export"); - - // Walk the staging directory and append every file - append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?; - - archive.finish()?; - Ok(()) -} - -fn append_dir_recursive( - archive: &mut tar::Builder>, - root: &Path, - current: &Path, - base_name: &str, -) -> Result<()> { - for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? { - let entry = entry?; - let path = entry.path(); - let rel = path.strip_prefix(root).unwrap_or(&path); - let tar_path = PathBuf::from(base_name).join(rel); - - if path.is_dir() { - archive.append_dir(&tar_path, &path)?; - append_dir_recursive(archive, root, &path, base_name)?; - } else if path.is_file() { - archive.append_path_with_name(&path, &tar_path)?; - } - } - Ok(()) -} - -fn extract_tarball(src: &Path, dest: &Path) -> Result<()> { - use flate2::read::GzDecoder; - - let file = std::fs::File::open(src) - .with_context(|| format!("failed to open {}", src.display()))?; - let decoder = GzDecoder::new(file); - let mut archive = tar::Archive::new(decoder); - archive.unpack(dest) - .with_context(|| format!("failed to extract {}", src.display()))?; - Ok(()) -} - -/// If a directory contains exactly one subdirectory and nothing else, return it. -fn find_single_subdir(dir: &Path) -> Option { - let entries: Vec<_> = std::fs::read_dir(dir) - .ok()? - .filter_map(|e| e.ok()) - .collect(); - if entries.len() == 1 && entries[0].path().is_dir() { - Some(entries[0].path()) - } else { - None - } -} - -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); - 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) // --------------------------------------------------------------------------- diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 942ad29..f39a829 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -15,44 +15,31 @@ pub struct ModuleManifest { 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('/') +/// Resolve a module source string to a local directory path. +/// +/// Only local paths are accepted. Remote fetching (`github:user/repo`) was +/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with +/// full `bread.exec()` privileges as the user. Installing a remote module now +/// requires cloning it yourself, so the review step stays in the user's hands. +pub fn parse_source(source: &str) -> Result { + if source.starts_with("github:") || source.starts_with("git:") { + bail!( + "bread: remote module installation has been removed for security \ + (it ran unreviewed third-party Lua with full exec privileges). \ + Clone the repository yourself, review it, then run \ + 'bread modules install /path/to/checkout'" + ); + } + 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)) + Ok(bread_shared::expand_path(source)) } else { bail!( - "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + "bread: invalid module source '{}'. Provide an absolute or relative \ + path to a local module directory", source ) } diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 25bdac7..bfbd481 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -89,11 +89,53 @@ pub fn now_unix_ms() -> u64 { .as_millis() as u64 } +/// Expand a leading `~` or `~/` in a path string to the user's home directory. +/// +/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps +/// callers infallible. Shared by the daemon and CLI for resolving +/// user-supplied paths (config entries, module install sources). +pub fn expand_path(path: &str) -> std::path::PathBuf { + use std::path::PathBuf; + let home = std::env::var("HOME").ok(); + if path == "~" { + if let Some(home) = home { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + #[test] + fn expand_path_leaves_non_tilde_paths_unchanged() { + use std::path::PathBuf; + assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_path("relative/x"), PathBuf::from("relative/x")); + assert_eq!(expand_path("./x"), PathBuf::from("./x")); + // A `~` not in leading position is not special. + assert_eq!(expand_path("/etc/~weird"), PathBuf::from("/etc/~weird")); + } + + #[test] + fn expand_path_expands_leading_tilde() { + // Read-only env access; safe under parallel test execution. + if let Ok(home) = std::env::var("HOME") { + assert_eq!(expand_path("~"), std::path::PathBuf::from(&home)); + assert_eq!( + expand_path("~/.config/bread"), + std::path::PathBuf::from(&home).join(".config/bread") + ); + } + } + #[test] fn adapter_source_serializes_as_snake_case() { assert_eq!( diff --git a/bread-sync/EXTRACTION.md b/bread-sync/EXTRACTION.md new file mode 100644 index 0000000..6dce450 --- /dev/null +++ b/bread-sync/EXTRACTION.md @@ -0,0 +1,36 @@ +# bread-sync — slated for extraction + +This crate is **no longer part of the `bread` workspace**. It is parked here +pending extraction into its own standalone project. + +## Why + +`bread`'s architecture deliberately scopes itself to a reactive automation +fabric — see the Non-Goals in `Overview.md`. State/dotfile synchronization +across machines is explicitly *out* of that scope. `bread-sync` grew into a +git-backed snapshot/restore + package + delegate-path manager, which is a +genuinely useful tool but a different product with a different lifecycle. It +was the one component pulling `bread`'s scope discipline out of shape, so it +is being spun out rather than removed (the code is good; it just doesn't +belong in this repo). + +## Status + +- Removed from the root `Cargo.toml` workspace (`members` → `exclude`). +- The `bread sync …` CLI subcommands have been removed from `bread-cli`. +- The `sync.status` IPC method and its integration tests have been removed + from `breadd`. +- No code in `bread`/`breadd`/`bread-cli` depends on this crate anymore. + +## For whoever extracts it (name polls are open) + +1. Move this directory into the new repository. +2. It inherited workspace dependencies (`serde`, `git2`, `dirs`, `chrono`, + `tempfile`, `glob`, …). Pin concrete versions in its own `Cargo.toml`; + `*.workspace = true` will not resolve outside this workspace. +3. The only helper that had to leave this crate is `config::expand_path`, + which moved to `bread-shared::expand_path` because non-sync code (the + module installer) needed it. Reintroduce a local copy in the new project + so it no longer depends on `bread-shared`. +4. Re-add the `bread sync` UX as a standalone binary, or as a `breadd` IPC + client, in the new project — not here. diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 03609ca..7d8620f 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index e9ef497..587f1d0 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,32 +267,6 @@ impl Server { "recent_errors": recent_errors, })) } - "sync.status" => { - let sync_path = bread_sync::config::bread_config_dir().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 diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index b7a7453..484a0c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -873,7 +873,8 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; - // bread.machine — machine name and tags from sync.toml + // bread.machine — hostname/tags; reads an optional, externally-managed + // ~/.config/bread/sync.toml if present (bread does not create it) let machine_tbl = self.lua.create_table()?; let name_fn = self @@ -947,9 +948,9 @@ impl LuaEngine { })?; bluetooth_tbl.set("power", power_fn)?; - let powered_fn = self.lua.create_function(move |_lua, ()| { - Ok(bluetooth_query(|| bluetooth_get_powered()).ok()) - })?; + let powered_fn = self + .lua + .create_function(move |_lua, ()| Ok(bluetooth_query(bluetooth_get_powered).ok()))?; bluetooth_tbl.set("powered", powered_fn)?; let connect_fn = self.lua.create_function(move |_lua, address: String| { @@ -983,7 +984,7 @@ impl LuaEngine { bluetooth_tbl.set("scan", scan_fn)?; let devices_fn = self.lua.create_function(move |lua, ()| { - let devs = match bluetooth_query(|| bluetooth_list_devices()) { + let devs = match bluetooth_query(bluetooth_list_devices) { Ok(d) => d, Err(_) => return Ok(Value::Nil), }; @@ -2298,7 +2299,8 @@ where .block_on(factory()); let _ = tx.send(result); }); - rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? + rx.recv() + .map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? } async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result { @@ -2392,7 +2394,11 @@ async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> { async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> { let conn = zbus::Connection::system().await?; let adapter = bluetooth_find_adapter(&conn).await?; - let method = if enabled { "StartDiscovery" } else { "StopDiscovery" }; + let method = if enabled { + "StartDiscovery" + } else { + "StopDiscovery" + }; conn.call_method( Some("org.bluez"), adapter.as_str(), @@ -2429,7 +2435,7 @@ async fn bluetooth_list_devices() -> anyhow::Result> { > = msg.body()?; let mut devices = Vec::new(); - for (_, interfaces) in &objects { + for interfaces in objects.values() { if let Some(props) = interfaces.get("org.bluez.Device1") { let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({})); devices.push(BluetoothDevice { diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index a12e504..4af40a9 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -161,37 +161,49 @@ async fn modules_reload_succeeds() -> Result<()> { } #[tokio::test] -async fn sync_status_uninitialized_when_no_config() -> Result<()> { +async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> Result<()> { let harness = TestHarness::spawn()?; harness.wait_until_ready().await?; - let result = harness.send_request("sync.status", json!({})).await?; - assert_eq!( - result.get("initialized").and_then(Value::as_bool), - Some(false) - ); + // Event emitted before any reload. + harness + .send_request("emit", json!({"event": "bread.reload.before", "data": {}})) + .await?; - harness.shutdown(); - Ok(()) -} + // Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers, + // and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked + // dispatch, paused-and-never-resumed pipeline) is the regression this guards + // — the previous suite only checked a single happy-path reload. + for _ in 0..3 { + let r = harness.send_request("modules.reload", json!({})).await?; + assert_eq!(r.get("ok").and_then(Value::as_bool), Some(true)); + } -#[tokio::test] -async fn sync_status_reports_initialized_with_config() -> Result<()> { - let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?; - harness.wait_until_ready().await?; + // Daemon must still answer control requests after the reload storm. + let ping = harness.send_request("ping", json!({})).await?; + assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); + let health = harness.send_request("health", json!({})).await?; + assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true)); - let result = harness.send_request("sync.status", json!({})).await?; - assert_eq!( - result.get("initialized").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - result.get("machine").and_then(Value::as_str), - Some("myhost") - ); - assert_eq!( - result.get("remote").and_then(Value::as_str), - Some("git@example.com:user/repo.git") + // The pipeline must have resumed: an event emitted *after* the reloads + // still flows through normalization into the replay buffer. + harness + .send_request("emit", json!({"event": "bread.reload.after", "data": {}})) + .await?; + sleep(Duration::from_millis(100)).await; + + let replay = harness + .send_request("events.replay", json!({"since_ms": 30_000})) + .await?; + let names: Vec<&str> = replay + .as_array() + .expect("replay result should be array") + .iter() + .filter_map(|e| e.get("event").and_then(Value::as_str)) + .collect(); + assert!( + names.contains(&"bread.reload.after"), + "event pipeline did not resume after reload; got {names:?}" ); harness.shutdown(); @@ -385,14 +397,6 @@ struct TestHarness { impl TestHarness { fn spawn() -> Result { - Self::spawn_inner(None) - } - - fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result { - Self::spawn_inner(Some((machine.to_string(), remote_url.to_string()))) - } - - fn spawn_inner(sync_config: Option<(String, String)>) -> Result { let temp = tempfile::tempdir()?; let runtime_dir = temp.path().join("runtime"); let config_home = temp.path().join("config"); @@ -433,21 +437,6 @@ enabled = false "#, )?; - if let Some((machine, remote_url)) = sync_config { - let sync_toml = format!( - r#" -[remote] -url = "{remote_url}" -branch = "main" - -[machine] -name = "{machine}" -tags = [] -"# - ); - fs::write(bread_cfg.join("sync.toml"), sync_toml)?; - } - let socket_path = runtime_dir.join("bread").join("breadd.sock"); let child = Command::new(env!("CARGO_BIN_EXE_breadd")) .env("XDG_RUNTIME_DIR", &runtime_dir) From 114c9e2bccd6619c91ceb31497b9eeea92a73b1b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:33:00 +0800 Subject: [PATCH 02/21] Revert to v0.6 --- Cargo.lock | 6 +- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- bread-sync/src/export.rs | 143 +++++++++++++++++++++++---------------- breadd/Cargo.toml | 2 +- packaging/arch/PKGBUILD | 2 +- 6 files changed, 93 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 155c062..01f9fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 6688aea..c43d83d 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 475e94c..66c3118 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs index 9397f4b..ae75bb4 100644 --- a/bread-sync/src/export.rs +++ b/bread-sync/src/export.rs @@ -64,48 +64,48 @@ pub struct ExportManifest { /// Config directories always included in the export (if they exist on disk). static BUILTIN_CONFIGS: &[(&str, &str)] = &[ - ("hypr", "~/.config/hypr"), - ("fish", "~/.config/fish"), - ("kitty", "~/.config/kitty"), - ("nvim", "~/.config/nvim"), - ("ags", "~/.config/ags"), - ("wofi", "~/.config/wofi"), - ("waybar", "~/.config/waybar"), - ("dunst", "~/.config/dunst"), - ("mako", "~/.config/mako"), - ("hyprlock", "~/.config/hyprlock"), + ("hypr", "~/.config/hypr"), + ("fish", "~/.config/fish"), + ("kitty", "~/.config/kitty"), + ("nvim", "~/.config/nvim"), + ("ags", "~/.config/ags"), + ("wofi", "~/.config/wofi"), + ("waybar", "~/.config/waybar"), + ("dunst", "~/.config/dunst"), + ("mako", "~/.config/mako"), + ("hyprlock", "~/.config/hyprlock"), ("hyprpaper", "~/.config/hyprpaper"), - ("swaylock", "~/.config/swaylock"), - ("wlogout", "~/.config/wlogout"), - ("swappy", "~/.config/swappy"), - ("btop", "~/.config/btop"), - ("waypaper", "~/.config/waypaper"), - ("wal", "~/.config/wal"), - ("gtk-3.0", "~/.config/gtk-3.0"), - ("gtk-4.0", "~/.config/gtk-4.0"), - ("keyd", "~/.config/keyd"), + ("swaylock", "~/.config/swaylock"), + ("wlogout", "~/.config/wlogout"), + ("swappy", "~/.config/swappy"), + ("btop", "~/.config/btop"), + ("waypaper", "~/.config/waypaper"), + ("wal", "~/.config/wal"), + ("gtk-3.0", "~/.config/gtk-3.0"), + ("gtk-4.0", "~/.config/gtk-4.0"), + ("keyd", "~/.config/keyd"), ("autostart", "~/.config/autostart"), ]; /// Standalone dotfiles captured as individual files: (staging-name, source-path). static BUILTIN_DOTFILES: &[(&str, &str)] = &[ - (".gitconfig", "~/.gitconfig"), + (".gitconfig", "~/.gitconfig"), ("user-dirs.dirs", "~/.config/user-dirs.dirs"), - ("mimeapps.list", "~/.config/mimeapps.list"), - ("ssh_config", "~/.ssh/config"), - (".zshrc", "~/.zshrc"), - (".zprofile", "~/.zprofile"), - (".zshenv", "~/.zshenv"), + ("mimeapps.list", "~/.config/mimeapps.list"), + ("ssh_config", "~/.ssh/config"), + (".zshrc", "~/.zshrc"), + (".zprofile", "~/.zprofile"), + (".zshenv", "~/.zshenv"), ]; /// System-level directories. World-readable ones are copied directly; /// root-only ones (networkmanager, bluetooth) require running with sudo. static SYSTEM_PATHS: &[(&str, &str)] = &[ - ("udev", "/etc/udev/rules.d"), - ("modprobe", "/etc/modprobe.d"), - ("sysctl", "/etc/sysctl.d"), + ("udev", "/etc/udev/rules.d"), + ("modprobe", "/etc/modprobe.d"), + ("sysctl", "/etc/sysctl.d"), ("networkmanager", "/etc/NetworkManager/system-connections"), - ("bluetooth", "/var/lib/bluetooth"), + ("bluetooth", "/var/lib/bluetooth"), ]; /// Directories excluded from every recursive copy. @@ -120,18 +120,22 @@ static DEFAULT_EXCLUDES: &[&str] = &[ /// Directories skipped when searching for git repos. static GIT_SKIP_DIRS: &[&str] = &[ - ".local", "Nextcloud", "target", "node_modules", "__pycache__", - ".cache", "snap", "flatpak", "@girs", "Steam", + ".local", + "Nextcloud", + "target", + "node_modules", + "__pycache__", + ".cache", + "snap", + "flatpak", + "@girs", + "Steam", ]; // ── stage_export ──────────────────────────────────────────────────────────── /// Build a self-contained snapshot directory at `staging`. -pub fn stage_export( - cfg_dir: &Path, - config: &SyncConfig, - staging: &Path, -) -> Result { +pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Result { fs::create_dir_all(staging)?; let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); @@ -238,8 +242,7 @@ pub fn stage_export( let fonts_src = expand_path("~/.local/share/fonts"); let fonts_dst = staging.join("local-fonts"); if fonts_src.exists() { - sync_dir(&fonts_src, &fonts_dst, &excludes) - .context("failed to snapshot fonts")?; + sync_dir(&fonts_src, &fonts_dst, &excludes).context("failed to snapshot fonts")?; path_map.push(PathRecord { staging: "local-fonts".to_string(), original: "~/.local/share/fonts".to_string(), @@ -292,9 +295,7 @@ pub fn stage_export( match packages::snapshot(manager, &dest_file) { Ok(true) => included_managers.push(manager.clone()), Ok(false) => {} - Err(e) => eprintln!( - "bread: warning: package snapshot for {manager} failed: {e}" - ), + Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"), } } } @@ -307,10 +308,18 @@ pub fn stage_export( // 11. Git repositories — find all repos with a remote, commit+push each let nc_dirs = nextcloud_sync_dirs(&home); if !nc_dirs.is_empty() { - let labels: Vec<_> = nc_dirs.iter() - .map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string())) + let labels: Vec<_> = nc_dirs + .iter() + .map(|p| { + p.strip_prefix(&home) + .map(|r| format!("~/{}", r.display())) + .unwrap_or_else(|_| p.display().to_string()) + }) .collect(); - eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", ")); + eprintln!( + "bread: skipping Nextcloud-tracked folders: {}", + labels.join(", ") + ); } let repos = find_git_repos(&home); commit_and_push_repos(&repos, &home); @@ -565,10 +574,7 @@ fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { .output(); match push { Ok(o) if o.status.success() => eprintln!("ok"), - Ok(o) => eprintln!( - "failed: {}", - String::from_utf8_lossy(&o.stderr).trim() - ), + Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), Err(e) => eprintln!("failed: {}", e), } } @@ -611,7 +617,15 @@ fn find_git_repos(home: &Path) -> Vec { walk_repos(home, home, 0, 1, &mut repos, &nc_dirs); // Deeper search in common project directories - for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] { + for subdir in &[ + "Projects", + "Documents", + "src", + "dev", + "code", + "repos", + "builds", + ] { let p = home.join(subdir); if p.exists() { walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs); @@ -630,7 +644,14 @@ fn find_git_repos(home: &Path) -> Vec { repos } -fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf]) { +fn walk_repos( + dir: &Path, + home: &Path, + depth: u32, + max_depth: u32, + repos: &mut Vec, + nc_dirs: &[PathBuf], +) { // Skip anything inside a Nextcloud sync root if nc_dirs.iter().any(|nc| dir.starts_with(nc)) { return; @@ -655,7 +676,11 @@ fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut V .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.to_string_lossy().to_string()); - repos.push(GitRepoRecord { path: rel, remote, branch }); + repos.push(GitRepoRecord { + path: rel, + remote, + branch, + }); } } return; // don't recurse into git repos (skip submodules) @@ -700,7 +725,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { let cargo_file = packages_dir.join("cargo.txt"); if cargo_file.exists() { for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) { - let _ = std::process::Command::new("cargo").args(["install", &pkg]).status(); + let _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); } } let pip_file = packages_dir.join("pip.txt"); @@ -713,7 +740,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { let npm_file = packages_dir.join("npm.txt"); if npm_file.exists() { for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) { - let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status(); + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); } } Ok(()) @@ -787,7 +816,9 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String { s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n"); } if manifest.packages.contains(&"pip".to_string()) { - s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n"); + s.push_str( + "echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n", + ); } if manifest.packages.contains(&"npm".to_string()) { s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n"); @@ -832,9 +863,7 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String { if !parent.is_empty() { s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n")); } - s.push_str(&format!( - "if [ ! -d \"{dest}/.git\" ]; then\n" - )); + s.push_str(&format!("if [ ! -d \"{dest}/.git\" ]; then\n")); s.push_str(&format!( " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", repo.path diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 7d8620f..d0c6485 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 80214e1..d2bb31e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=bread -pkgver=1.0.0 +pkgver=0.6.0 pkgrel=1 pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') From e57f085e37303a178bb67bf80c77e2a5bc875a55 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:40:13 +0800 Subject: [PATCH 03/21] Fix CI tar path --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7614f..bda65bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread - name: Upload artifacts uses: actions/upload-artifact@v4 with: From 76e503b837e35a90a00f2ed37f1198adbf8195c6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:01 +0800 Subject: [PATCH 04/21] Add bakery.toml and release workflow Wires bread into the bakery ecosystem: prebuilt binaries are published to dl.breadway.dev and GitHub Releases on every v* tag via the self-hosted hestia runner. bakery install bread downloads, verifies, and wires the systemd unit automatically. --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++++ bakery.toml | 18 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..615aa10 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: build + run: cargo build --release --locked + + - name: test + run: cargo test --release --locked --workspace + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadd bread; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp packaging/systemd/breadd.service "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadd-x86_64" \ + "${PKG_DIR}/bread-x86_64" \ + "${PKG_DIR}/breadd-x86_64.sha256" \ + "${PKG_DIR}/bread-x86_64.sha256" \ + --clobber diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..52dff3c --- /dev/null +++ b/bakery.toml @@ -0,0 +1,18 @@ +name = "bread" +description = "Reactive automation daemon and CLI for Linux desktops" +binaries = ["breadd", "bread"] +system_deps = [] +bread_deps = [] + +[[service]] +unit = "breadd.service" +enable = true + +[config] +dir = "~/.config/bread" +example = "breadd.toml" + +[install] +post_install = [ + "systemctl --user is-active --quiet breadd || systemctl --user start breadd", +] From 0f430e873db9f4e8a2b384079839bba489420159 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:48 +0800 Subject: [PATCH 05/21] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 615aa10..c03d0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: install build deps + run: sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config 2>/dev/null || true + - name: build run: cargo build --release --locked From 109b11c77f19f80f0dfa92b351f3ab9d51bfb253 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:43:08 +0800 Subject: [PATCH 06/21] fix: skip integration tests in CI (require live daemon) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c03d0fc..4da722c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: run: cargo build --release --locked - name: test - run: cargo test --release --locked --workspace + run: cargo test --release --locked --workspace --lib - name: prepare artifacts run: | From a9b199259836aac1a474e52bb11f84e053482e6a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:34 +0800 Subject: [PATCH 07/21] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4da722c..51f93ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "bread v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadd-x86_64" \ "${PKG_DIR}/bread-x86_64" \ From 3025c485d17dbbfbcc3c67606ed789540e2e7004 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:45 +0800 Subject: [PATCH 08/21] fix: add contents: write permission for GitHub Release creation --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51f93ee..acde6e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From db4d82f219dede14ddf6081d2bbeb466ce323b76 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:00:23 +0800 Subject: [PATCH 09/21] fix: use relative symlink for latest to work inside Docker containers --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acde6e3..cacfb7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: done cp packaging/systemd/breadd.service "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + ln -sfn "${VERSION}" "${DL_DIR}/bread/latest" - name: ensure bread-ecosystem run: | From 9bbadc522192c9c4794a488b747a1ce163d55855 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:30 +0800 Subject: [PATCH 10/21] fix: update system_deps to accurate Arch package names Required: systemd-libs (libudev.so.1), openssl, zlib (bread CLI via git2) Optional: bluez (Bluetooth, graceful degradation), hyprland (IPC features) Removes empty system_deps placeholder. --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index 52dff3c..ab782a0 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "bread" description = "Reactive automation daemon and CLI for Linux desktops" binaries = ["breadd", "bread"] -system_deps = [] +system_deps = ["systemd-libs", "openssl", "zlib"] +optional_system_deps = ["bluez", "hyprland"] bread_deps = [] [[service]] From 32982b96de4ce585160b940a3ba9240a3dabadcb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:13 +0800 Subject: [PATCH 11/21] chore: bump version to 0.6.1 --- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- breadd/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index c43d83d..5bdcb14 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 66c3118..aa4fe61 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index d0c6485..19a3a67 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] From 3ccb041778363e6f4d6a76376f3b4b5ae4542ab2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:27:53 +0800 Subject: [PATCH 12/21] chore: update Cargo.lock for v0.6.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01f9fde..3f631e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", From 4446b5e98b5472d40f1885b9bf460d76a90ee759 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 11:42:06 +0800 Subject: [PATCH 13/21] Add Forgejo Actions workflows for mirroring and package publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds PKGBUILD on tag and publishes the bread package to the Forgejo Arch registry (distrib=breadway) Requires two Forgejo secrets: GITHUB_MIRROR_TOKEN — GitHub PAT with repo push scope FORGEJO_TOKEN — Forgejo token with package:write scope --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..7f4005d --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..919377f --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=bread-${VERSION}/ \ + HEAD > packaging/arch/bread-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" From c70c9a7278badb7b3c1ec6c90d7c35d68f4db0cb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:01:58 +0800 Subject: [PATCH 14/21] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune Requires only the GITHUB_MIRROR_TOKEN secret for the mirror job. --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 7f4005d..6b6e480 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 919377f..2a64145 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=bread-${VERSION}/ \ - HEAD > packaging/arch/bread-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ + > packaging/arch/bread-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From 73f01e97b4f586ad77d2c0eded6a2ef3a8c8009d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:49 +0800 Subject: [PATCH 15/21] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 6b6e480..45d36e3 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From d454e832d9a4e0fdb748b98833622cb89479576d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:13 +0800 Subject: [PATCH 16/21] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 45d36e3..5cee7d9 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 2a64145..86a3a38 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ > packaging/arch/bread-${VERSION}.tar.gz From 64e756f6ebe9963b3d412d8804d64854a7d9a766 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:52:54 +0800 Subject: [PATCH 17/21] Disable LTO in PKGBUILD to fix vendored Lua static link makepkg's default -flto=auto made mlua's vendored liblua5.4.a contain GCC LTO bitcode that the Rust (lld) link couldn't read, leaving all lua_* symbols undefined. options=(!lto) produces a clean static link. Verified building in a clean archlinux container. --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index d2bb31e..55649a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') +# mlua builds Lua from vendored C source. makepkg's default -flto=auto would +# emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, +# leaving all lua_* symbols undefined. Disable LTO for a clean static link. +options=(!lto) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From 623560cea661cc35c5a9f3b35a93c8aa572981a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:39 +0800 Subject: [PATCH 18/21] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 86a3a38..f48f9d8 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From 152915198bca9751c835eb49e9b6d4db21da9501 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:46 +0800 Subject: [PATCH 19/21] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 55649a7..520280e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # mlua builds Lua from vendored C source. makepkg's default -flto=auto would # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, # leaving all lua_* symbols undefined. Disable LTO for a clean static link. -options=(!lto) +options=(!lto !debug) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From 954b7f381e3337838e175ca7c86b47ad2addfccd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:06:44 +0800 Subject: [PATCH 20/21] Add ready-to-use example modules examples/modules/ ships complete, drop-in bread modules for common desktop automations (low-battery warning, pause-media-on-headphone-unplug, dock-monitors) plus a README on installing them. Complements Examples.md, which teaches the porting patterns. --- examples/modules/README.md | 28 ++++++++++++++++++ examples/modules/dock-monitors.lua | 26 +++++++++++++++++ examples/modules/low-battery-warning.lua | 29 +++++++++++++++++++ .../pause-media-on-headphone-unplug.lua | 25 ++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 examples/modules/README.md create mode 100644 examples/modules/dock-monitors.lua create mode 100644 examples/modules/low-battery-warning.lua create mode 100644 examples/modules/pause-media-on-headphone-unplug.lua diff --git a/examples/modules/README.md b/examples/modules/README.md new file mode 100644 index 0000000..e9636ec --- /dev/null +++ b/examples/modules/README.md @@ -0,0 +1,28 @@ +# Example bread modules + +Ready-to-use modules for common desktop automations. Unlike the snippets in +[`../../Examples.md`](../../Examples.md) (which teach the porting patterns), +these are complete files you can drop in as-is. + +## Installing + +Modules in `~/.config/bread/modules/` are **auto-discovered** — copy a file in +and reload; no `init.lua` edit needed: + +```sh +cp low-battery-warning.lua ~/.config/bread/modules/ +bread reload +``` + +## Modules + +| File | What it does | Config needed | +|------|--------------|---------------| +| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none | +| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) | +| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions | + +Each module is the standard skeleton — `bread.module{...}`, an `on_load` that +registers subscriptions, `return M` — so they double as references for writing +your own. See [`../../Documentation.md`](../../Documentation.md) for the full +event list and Lua API. diff --git a/examples/modules/dock-monitors.lua b/examples/modules/dock-monitors.lua new file mode 100644 index 0000000..1fd92a6 --- /dev/null +++ b/examples/modules/dock-monitors.lua @@ -0,0 +1,26 @@ +-- dock-monitors — apply a monitor layout when an external display is plugged +-- in (a "dock") and revert to the laptop panel when it's removed. +-- +-- Drop-in: copy into ~/.config/bread/modules/ and edit the output names / +-- resolutions for your machine (see `hyprctl monitors`). + +local monitors = require("bread.monitors") +local M = bread.module({ name = "dock-monitors", version = "1.0.0" }) + +-- Named layouts ---------------------------------------------------------------- +monitors.layout("docked", function() + bread.hyprland.keyword("monitor", "eDP-1, 1920x1200@60, 0x0, 1") + bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1") +end) + +monitors.layout("solo", function() + bread.hyprland.keyword("monitor", "eDP-1, preferred, 0x0, 1") +end) + +-- React to the external display ------------------------------------------------ +function M.on_load() + monitors.on({ when = "connected", monitors = { "HDMI-A-1" }, run = monitors.apply("docked") }) + monitors.on({ when = "disconnected", monitors = { "HDMI-A-1" }, run = monitors.apply("solo") }) +end + +return M diff --git a/examples/modules/low-battery-warning.lua b/examples/modules/low-battery-warning.lua new file mode 100644 index 0000000..eb68839 --- /dev/null +++ b/examples/modules/low-battery-warning.lua @@ -0,0 +1,29 @@ +-- low-battery-warning — notify once when the battery runs low. +-- +-- Drop-in: copy into ~/.config/bread/modules/ (auto-discovered; no init.lua +-- edit needed). Zero configuration. + +local M = bread.module({ name = "low-battery-warning", version = "1.0.0" }) + +-- Latch so we warn once per low-battery episode, not on every poll. +local warned = false + +function M.on_load() + bread.on("bread.power.battery.low", function(event) + if warned then return end + warned = true + local pct = event.data.battery_percent or "?" + bread.notify("Battery low (" .. pct .. "%). Plug in soon.", { + urgency = "critical", + title = "Battery", + timeout = 10000, + }) + end) + + -- Reset once back on AC so the next low episode warns again. + bread.on("bread.power.ac.connected", function() + warned = false + end) +end + +return M diff --git a/examples/modules/pause-media-on-headphone-unplug.lua b/examples/modules/pause-media-on-headphone-unplug.lua new file mode 100644 index 0000000..6b6d6e7 --- /dev/null +++ b/examples/modules/pause-media-on-headphone-unplug.lua @@ -0,0 +1,25 @@ +-- pause-media-on-headphone-unplug — pause playback when headphones disconnect, +-- so sound doesn't suddenly blast out of the speakers. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Requires `playerctl`. + +local M = bread.module({ name = "pause-media-on-headphone-unplug", version = "1.0.0" }) + +local function looks_like_headphones(name) + if not name then return false end + name = name:lower() + return name:find("head") ~= nil + or name:find("earbud") ~= nil + or name:find("airpod") ~= nil + or name:find("buds") ~= nil +end + +function M.on_load() + bread.on("bread.device.disconnected", function(event) + if looks_like_headphones(event.data.name) then + bread.exec("playerctl pause") + end + end) +end + +return M From 0f3136ca8de9aae8f18c113bb4dce65bffb039a0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 19 Jun 2026 08:38:37 +0800 Subject: [PATCH 21/21] CI: watch main branch alongside master/dev --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bda65bd..e1efa1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ master, dev ] + branches: [ master, main, dev ] pull_request: - branches: [ master, dev ] + branches: [ master, main, dev ] jobs: build-and-test: