diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 5cee7d9..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - 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. - git push --prune \ - "https://x-access-token:${{ secrets.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 deleted file mode 100644 index f48f9d8..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # 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.REGISTRY_TOKEN }} - run: | - 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 \ - "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 - 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 - chown -R builder:builder /home/builder/src - # --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 ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bda65bd..7409b04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,31 +2,29 @@ name: CI on: push: - branches: [ master, dev ] + branches: [ main ] pull_request: - branches: [ master, dev ] + branches: [ main ] jobs: build-and-test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@v1 with: - toolchain: stable - components: clippy, rustfmt - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + toolchain: ${{ matrix.rust }} - 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 @@ -36,9 +34,9 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread + tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: bread-ubuntu-latest + name: bread-${{ matrix.os }} path: dist/*.tgz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index cacfb7a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -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: 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 - - - name: test - run: cargo test --release --locked --workspace --lib - - - 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 "${VERSION}" "${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 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" \ - "${PKG_DIR}/breadd-x86_64.sha256" \ - "${PKG_DIR}/bread-x86_64.sha256" \ - --clobber diff --git a/Cargo.lock b/Cargo.lock index 3f631e4..52ab50b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -257,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -293,17 +305,21 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.6.1" +version = "1.0.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", "chrono", "clap", "dirs", + "flate2", "libc", "notify", + "reqwest", "serde", "serde_json", + "tar", "tempfile", "tokio", "toml", @@ -311,19 +327,36 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.6.1" +version = "1.0.0" dependencies = [ "serde", "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 = "0.6.1" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", "bread-shared", + "bread-sync", "futures-util", "libc", "mlua", @@ -376,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -454,6 +489,26 @@ 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" @@ -469,6 +524,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -536,12 +600,32 @@ 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" @@ -665,12 +749,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -817,6 +941,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -825,11 +961,51 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -869,6 +1045,77 @@ 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" @@ -893,12 +1140,115 @@ 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" @@ -951,6 +1301,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -963,6 +1319,16 @@ 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" @@ -1013,6 +1379,20 @@ 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" @@ -1022,6 +1402,20 @@ 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" @@ -1032,6 +1426,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -1050,6 +1456,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1126,6 +1538,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1179,6 +1607,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1318,6 +1763,55 @@ 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" @@ -1378,6 +1872,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1431,6 +1931,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1478,6 +1987,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1563,6 +2078,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rtnetlink" version = "0.9.1" @@ -1624,12 +2179,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1639,12 +2209,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1724,6 +2326,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1760,6 +2374,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1792,6 +2412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1826,6 +2452,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1868,6 +2543,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1897,6 +2582,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1949,6 +2657,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2010,6 +2724,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -2057,6 +2777,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2069,6 +2807,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2091,6 +2835,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2128,6 +2881,16 @@ 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" @@ -2194,6 +2957,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2462,6 +3235,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -2562,6 +3345,22 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2572,6 +3371,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -2659,6 +3481,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7d0c7a7..8216be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,6 @@ 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 a06699f..36c6d73 100644 --- a/Documentation.md +++ b/Documentation.md @@ -7,6 +7,7 @@ - [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) @@ -100,16 +101,15 @@ 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 -# 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 +# 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 # List installed modules and their daemon status bread modules list @@ -117,6 +117,9 @@ 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 @@ -129,10 +132,101 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "/home/you/src/bread-wifi" +source = "github:someuser/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. @@ -302,13 +396,10 @@ Logging helpers. Accept any Lua value (coerced via `tostring`). ### Machine and filesystem #### `bread.machine.name() -> string` -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). +Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized. #### `bread.machine.tags() -> string[]` -Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file -exists, otherwise `{}`. +Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. #### `bread.machine.has_tag(tag) -> bool` Returns true if the machine has the given tag. @@ -833,3 +924,4 @@ 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 0fab7e2..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ 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 ``` @@ -193,9 +194,26 @@ bread profile-activate # Activate a named profile # Modules bread modules list # List installed modules and daemon status -bread modules install /local/path # Install from a local module directory +bread modules install github:user/repo # Install from GitHub +bread modules install /local/path # Install from a local directory bread modules remove # Remove an installed module +bread modules update [name] # Re-install one or all GitHub-sourced modules bread modules info # Show full manifest and daemon status + +# Sync +bread sync init # Initialize sync for this machine (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 ``` --- @@ -206,15 +224,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 -git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi -# review ~/src/bread-wifi, then: -bread modules install ~/src/bread-wifi +# From GitHub (downloads latest release tarball) +bread modules install github:someuser/bread-wifi + +# From a local path +bread modules install ~/src/my-module + +# From a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 ``` ### Writing a module @@ -234,7 +252,7 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "/home/you/src/bread-wifi" +source = "github:someuser/bread-wifi" installed_at = "2026-01-01T00:00:00Z" ``` @@ -251,6 +269,67 @@ 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...`. @@ -417,7 +496,7 @@ end ### Machine and filesystem ```lua --- Machine identity (system hostname) +-- Machine identity (from sync.toml, falls back to hostname) local name = bread.machine.name() local tags = bread.machine.tags() -- array of strings local ok = bread.machine.has_tag("laptop") @@ -537,6 +616,7 @@ 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. @@ -546,7 +626,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 and module system. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. --- diff --git a/bakery.toml b/bakery.toml deleted file mode 100644 index ab782a0..0000000 --- a/bakery.toml +++ /dev/null @@ -1,19 +0,0 @@ -name = "bread" -description = "Reactive automation daemon and CLI for Linux desktops" -binaries = ["breadd", "bread"] -system_deps = ["systemd-libs", "openssl", "zlib"] -optional_system_deps = ["bluez", "hyprland"] -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", -] diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 5bdcb14..1e4b667 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.6.1" +version = "1.0.0" edition = "2021" [[bin]] @@ -13,6 +13,7 @@ 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 @@ -23,6 +24,7 @@ clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" toml = "0.8" - -[dev-dependencies] +reqwest = { version = "0.11", features = ["json"] } +flate2 = "1.0" +tar = "0.4" tempfile.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 64d44a0..924c7b3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,6 +1,10 @@ mod modules_mgmt; -use anyhow::Result; +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, apply_import, stage_export, SyncRepo, +}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; @@ -58,6 +62,11 @@ 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 @@ -82,9 +91,9 @@ enum Commands { #[derive(Subcommand, Debug)] enum ModulesCommand { - /// Install a module from a local directory + /// Install a module from a source Install { - /// Path to a local module directory + /// Source: github:user/repo[@ref] or /path/to/dir source: String, }, /// Remove an installed module @@ -96,10 +105,66 @@ 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(); @@ -137,6 +202,9 @@ 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)?; @@ -189,7 +257,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = install_module(&source, &mods_dir)?; + let manifest = install_module(&source, &mods_dir).await?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -244,6 +312,39 @@ 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 { @@ -270,12 +371,74 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { Ok(()) } -fn install_module( +async fn install_module( source: &str, mods_dir: &std::path::Path, ) -> Result { - let path = modules_mgmt::parse_source(source)?; - modules_mgmt::install_from_local(&path, source, mods_dir) + match modules_mgmt::parse_source(source)? { + modules_mgmt::InstallSource::LocalPath(path) => { + modules_mgmt::install_from_local(&path, source, mods_dir) + } + modules_mgmt::InstallSource::GitHub { + user, + repo, + git_ref, + } => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await, + } +} + +async fn install_from_github( + user: &str, + repo: &str, + git_ref: Option<&str>, + source_str: &str, + mods_dir: &Path, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let ref_to_use = match git_ref { + Some(r) => r.to_string(), + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client + .get(&url) + .send() + .await + .context("failed to reach GitHub API")? + .json() + .await + .context("failed to parse GitHub API response")?; + resp.get("default_branch") + .and_then(Value::as_str) + .unwrap_or("main") + .to_string() + } + }; + + let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let bytes = client + .get(&tarball_url) + .send() + .await + .context("failed to download module archive")? + .bytes() + .await + .context("failed to read module archive")?; + + let tmp = tempfile::tempdir()?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + archive.unpack(tmp.path())?; + + // GitHub extracts to a single subdirectory (e.g. "user-repo-sha/") + let root = std::fs::read_dir(tmp.path())? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?; + + modules_mgmt::install_from_local(&root, source_str, mods_dir) } /// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. @@ -288,6 +451,576 @@ 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 f39a829..942ad29 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -15,31 +15,44 @@ pub struct ModuleManifest { pub installed_at: String, } -/// 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('/') +/// Parsed install source. +pub enum InstallSource { + GitHub { + user: String, + repo: String, + git_ref: Option, + }, + LocalPath(PathBuf), +} + +/// Parse a source string into an `InstallSource`. +pub fn parse_source(source: &str) -> Result { + if let Some(rest) = source.strip_prefix("github:") { + let (repo_part, ref_part) = rest + .split_once('@') + .map(|(r, v)| (r, Some(v.to_string()))) + .unwrap_or((rest, None)); + let (user, repo) = repo_part.split_once('/').ok_or_else(|| { + anyhow::anyhow!( + "bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'", + source + ) + })?; + Ok(InstallSource::GitHub { + user: user.to_string(), + repo: repo.to_string(), + git_ref: ref_part, + }) + } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("../") || source.starts_with('~') { - Ok(bread_shared::expand_path(source)) + let expanded = bread_sync::config::expand_path(source); + Ok(InstallSource::LocalPath(expanded)) } else { bail!( - "bread: invalid module source '{}'. Provide an absolute or relative \ - path to a local module directory", + "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", source ) } diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index aa4fe61..475e94c 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "0.6.1" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index bfbd481..25bdac7 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -89,53 +89,11 @@ 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 deleted file mode 100644 index 6dce450..0000000 --- a/bread-sync/EXTRACTION.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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/bread-sync/src/export.rs b/bread-sync/src/export.rs index ae75bb4..9397f4b 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,22 +120,18 @@ 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(); @@ -242,7 +238,8 @@ pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Resu 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(), @@ -295,7 +292,9 @@ pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Resu 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}" + ), } } } @@ -308,18 +307,10 @@ pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Resu // 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); @@ -574,7 +565,10 @@ 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), } } @@ -617,15 +611,7 @@ 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); @@ -644,14 +630,7 @@ 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; @@ -676,11 +655,7 @@ fn walk_repos( .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) @@ -725,9 +700,7 @@ 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"); @@ -740,9 +713,7 @@ 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(()) @@ -816,9 +787,7 @@ 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"); @@ -863,7 +832,9 @@ 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 19a3a67..03609ca 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "breadd" -version = "0.6.1" +version = "1.0.0" 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 587f1d0..e9ef497 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,6 +267,32 @@ 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 484a0c6..b7a7453 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -873,8 +873,7 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; - // bread.machine — hostname/tags; reads an optional, externally-managed - // ~/.config/bread/sync.toml if present (bread does not create it) + // bread.machine — machine name and tags from sync.toml let machine_tbl = self.lua.create_table()?; let name_fn = self @@ -948,9 +947,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| { @@ -984,7 +983,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), }; @@ -2299,8 +2298,7 @@ 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 { @@ -2394,11 +2392,7 @@ 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(), @@ -2435,7 +2429,7 @@ async fn bluetooth_list_devices() -> anyhow::Result> { > = msg.body()?; let mut devices = Vec::new(); - for interfaces in objects.values() { + for (_, interfaces) in &objects { 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 4af40a9..a12e504 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -161,49 +161,37 @@ async fn modules_reload_succeeds() -> Result<()> { } #[tokio::test] -async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> Result<()> { +async fn sync_status_uninitialized_when_no_config() -> Result<()> { let harness = TestHarness::spawn()?; harness.wait_until_ready().await?; - // Event emitted before any reload. - harness - .send_request("emit", json!({"event": "bread.reload.before", "data": {}})) - .await?; + let result = harness.send_request("sync.status", json!({})).await?; + assert_eq!( + result.get("initialized").and_then(Value::as_bool), + Some(false) + ); - // 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)); - } + harness.shutdown(); + Ok(()) +} - // 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)); +#[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?; - // 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:?}" + 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") ); harness.shutdown(); @@ -397,6 +385,14 @@ 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"); @@ -437,6 +433,21 @@ 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) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 55649a7..80214e1 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,16 +1,12 @@ # Maintainer: Breadway pkgname=bread -pkgver=0.6.0 +pkgver=1.0.0 pkgrel=1 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()'