Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e756f6eb | ||
|
|
d454e832d9 | ||
|
|
73f01e97b4 | ||
|
|
c70c9a7278 | ||
|
|
4446b5e98b | ||
|
|
3ccb041778 | ||
|
|
32982b96de | ||
|
|
9bbadc5221 | ||
|
|
db4d82f219 | ||
|
|
3025c485d1 | ||
|
|
a9b1992598 | ||
|
|
109b11c77f | ||
|
|
0f430e873d | ||
|
|
76e503b837 | ||
|
|
e57f085e37 | ||
|
|
114c9e2bcc | ||
|
|
cc456b78fe |
21 changed files with 448 additions and 2010 deletions
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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/*'
|
||||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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.GITHUB_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"
|
||||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
|
@ -2,29 +2,31 @@ name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ master, dev ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
strategy:
|
runs-on: ubuntu-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest]
|
|
||||||
rust: [stable]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@v1
|
uses: dtolnay/rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ matrix.rust }}
|
toolchain: stable
|
||||||
|
components: clippy, rustfmt
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
|
||||||
- name: Cargo cache
|
- name: Cargo cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --workspace --verbose
|
run: cargo build --workspace --verbose
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|
@ -34,9 +36,9 @@ jobs:
|
||||||
- name: Package artifacts
|
- name: Package artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli
|
tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: bread-${{ matrix.os }}
|
name: bread-ubuntu-latest
|
||||||
path: dist/*.tgz
|
path: dist/*.tgz
|
||||||
|
|
|
||||||
69
.github/workflows/release.yml
vendored
Normal file
69
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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
|
||||||
884
Cargo.lock
generated
884
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,10 @@ members = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-cli",
|
"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",
|
"bread-sync",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
|
||||||
122
Documentation.md
122
Documentation.md
|
|
@ -7,7 +7,6 @@
|
||||||
- [Your first module](#your-first-module)
|
- [Your first module](#your-first-module)
|
||||||
- [Run, reload, and watch](#run-reload-and-watch)
|
- [Run, reload, and watch](#run-reload-and-watch)
|
||||||
- [Modules: install and manage](#modules-install-and-manage)
|
- [Modules: install and manage](#modules-install-and-manage)
|
||||||
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
|
|
||||||
- [Debugging tips](#debugging-tips)
|
- [Debugging tips](#debugging-tips)
|
||||||
- [Dictionary: Lua API](#dictionary-lua-api)
|
- [Dictionary: Lua API](#dictionary-lua-api)
|
||||||
- [Bluetooth](#bluetooth)
|
- [Bluetooth](#bluetooth)
|
||||||
|
|
@ -101,15 +100,16 @@ If any module fails to load, `bread reload` prints the error with a full Lua sta
|
||||||
|
|
||||||
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
|
Modules 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
|
```bash
|
||||||
# Install from GitHub (downloads and extracts the default branch tarball)
|
# Clone and review, then install from the local checkout
|
||||||
bread modules install github:someuser/bread-wifi
|
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
||||||
|
bread modules install ~/src/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
|
# List installed modules and their daemon status
|
||||||
bread modules list
|
bread modules list
|
||||||
|
|
@ -117,9 +117,6 @@ bread modules list
|
||||||
# Show full manifest for one module
|
# Show full manifest for one module
|
||||||
bread modules info bread-wifi
|
bread modules info bread-wifi
|
||||||
|
|
||||||
# Re-install all GitHub-sourced modules (pick up upstream changes)
|
|
||||||
bread modules update
|
|
||||||
|
|
||||||
# Remove a module
|
# Remove a module
|
||||||
bread modules remove bread-wifi
|
bread modules remove bread-wifi
|
||||||
bread modules remove bread-wifi --yes # skip confirmation
|
bread modules remove bread-wifi --yes # skip confirmation
|
||||||
|
|
@ -132,101 +129,10 @@ name = "wifi"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "WiFi management for Bread"
|
description = "WiFi management for Bread"
|
||||||
author = "someuser"
|
author = "someuser"
|
||||||
source = "github:someuser/bread-wifi"
|
source = "/home/you/src/bread-wifi"
|
||||||
installed_at = "2026-01-01T00:00:00Z"
|
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-<machine>-<date>.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
|
## Debugging tips
|
||||||
|
|
||||||
- Run `bread events` to see live normalized events.
|
- Run `bread events` to see live normalized events.
|
||||||
|
|
@ -396,10 +302,13 @@ Logging helpers. Accept any Lua value (coerced via `tostring`).
|
||||||
### Machine and filesystem
|
### Machine and filesystem
|
||||||
|
|
||||||
#### `bread.machine.name() -> string`
|
#### `bread.machine.name() -> string`
|
||||||
Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized.
|
Returns the system hostname. If an external tool has written a
|
||||||
|
`~/.config/bread/sync.toml` with a `[machine].name`, that value takes
|
||||||
|
precedence (bread reads the file if present but does not create it).
|
||||||
|
|
||||||
#### `bread.machine.tags() -> string[]`
|
#### `bread.machine.tags() -> string[]`
|
||||||
Returns the tags array from `sync.toml`, or `{}` if sync is not initialized.
|
Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file
|
||||||
|
exists, otherwise `{}`.
|
||||||
|
|
||||||
#### `bread.machine.has_tag(tag) -> bool`
|
#### `bread.machine.has_tag(tag) -> bool`
|
||||||
Returns true if the machine has the given tag.
|
Returns true if the machine has the given tag.
|
||||||
|
|
@ -924,4 +833,3 @@ Available methods:
|
||||||
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
||||||
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
||||||
| `emit` | `event`, `data` | Inject a synthetic event into the pipeline |
|
| `emit` | `event`, `data` | Inject a synthetic event into the pipeline |
|
||||||
| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` |
|
|
||||||
|
|
|
||||||
104
README.md
104
README.md
|
|
@ -45,7 +45,6 @@ return M
|
||||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
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
|
packaging/ Arch PKGBUILD and systemd user service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -194,26 +193,9 @@ bread profile-activate <name> # Activate a named profile
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
bread modules list # List installed modules and daemon status
|
bread modules list # List installed modules and daemon status
|
||||||
bread modules install github:user/repo # Install from GitHub
|
bread modules install /local/path # Install from a local module directory
|
||||||
bread modules install /local/path # Install from a local directory
|
|
||||||
bread modules remove <name> # Remove an installed module
|
bread modules remove <name> # Remove an installed module
|
||||||
bread modules update [name] # Re-install one or all GitHub-sourced modules
|
|
||||||
bread modules info <name> # Show full manifest and daemon status
|
bread modules info <name> # 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 <path> # Apply a portable snapshot (.tar.gz or directory)
|
|
||||||
bread sync import <path> --install-packages # Also install packages
|
|
||||||
bread sync import <path> --no-clone-repos # Skip cloning git repos
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -224,15 +206,15 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`.
|
||||||
|
|
||||||
### Installing 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
|
```bash
|
||||||
# From GitHub (downloads latest release tarball)
|
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
||||||
bread modules install github:someuser/bread-wifi
|
# review ~/src/bread-wifi, then:
|
||||||
|
bread modules install ~/src/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
|
### Writing a module
|
||||||
|
|
@ -252,7 +234,7 @@ name = "wifi"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "WiFi management for Bread"
|
description = "WiFi management for Bread"
|
||||||
author = "someuser"
|
author = "someuser"
|
||||||
source = "github:someuser/bread-wifi"
|
source = "/home/you/src/bread-wifi"
|
||||||
installed_at = "2026-01-01T00:00:00Z"
|
installed_at = "2026-01-01T00:00:00Z"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -269,67 +251,6 @@ return M
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sync system
|
|
||||||
|
|
||||||
Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# First-time setup (remote is optional)
|
|
||||||
bread sync init
|
|
||||||
bread sync init --remote git@github.com:you/bread-config.git
|
|
||||||
|
|
||||||
# Commit a local snapshot
|
|
||||||
bread sync push
|
|
||||||
|
|
||||||
# Create a portable .tar.gz (no git auth required)
|
|
||||||
bread sync export
|
|
||||||
|
|
||||||
# On another machine: apply the snapshot
|
|
||||||
bread sync import bread-export-hermes-2026-05-16.tar.gz
|
|
||||||
|
|
||||||
# Also install packages on import
|
|
||||||
bread sync import bread-export.tar.gz --install-packages
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure what gets synced in `~/.config/bread/sync.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[remote]
|
|
||||||
url = "git@github.com:you/bread-config.git" # optional
|
|
||||||
branch = "main"
|
|
||||||
|
|
||||||
[machine]
|
|
||||||
name = "hermes"
|
|
||||||
tags = ["laptop", "battery"]
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
enabled = true
|
|
||||||
managers = ["pacman", "pip", "cargo"]
|
|
||||||
|
|
||||||
[delegates]
|
|
||||||
include = ["~/.config/nvim", "~/.config/waybar"]
|
|
||||||
exclude = ["**/.git", "**/*.cache"]
|
|
||||||
```
|
|
||||||
|
|
||||||
A portable export snapshot contains:
|
|
||||||
|
|
||||||
```
|
|
||||||
bread-export-hermes-2026-05-16/
|
|
||||||
├── bread/ ← ~/.config/bread/
|
|
||||||
├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, …
|
|
||||||
├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, …
|
|
||||||
├── local-bin/ ← ~/.local/bin/ scripts
|
|
||||||
├── local-fonts/ ← ~/.local/share/fonts/
|
|
||||||
├── systemd/ ← ~/.config/systemd/user/ units
|
|
||||||
├── system/ ← udev rules, modprobe, sysctl (sudo required for some)
|
|
||||||
├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt
|
|
||||||
├── machines/ ← per-machine profiles
|
|
||||||
├── manifest.toml ← path map for exact restore
|
|
||||||
└── restore.sh ← shell script for manual restore
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event reference
|
## Event reference
|
||||||
|
|
||||||
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
||||||
|
|
@ -496,7 +417,7 @@ end
|
||||||
### Machine and filesystem
|
### Machine and filesystem
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
-- Machine identity (from sync.toml, falls back to hostname)
|
-- Machine identity (system hostname)
|
||||||
local name = bread.machine.name()
|
local name = bread.machine.name()
|
||||||
local tags = bread.machine.tags() -- array of strings
|
local tags = bread.machine.tags() -- array of strings
|
||||||
local ok = bread.machine.has_tag("laptop")
|
local ok = bread.machine.has_tag("laptop")
|
||||||
|
|
@ -616,7 +537,6 @@ Available methods:
|
||||||
| `events.subscribe` | Upgrade connection to streaming mode |
|
| `events.subscribe` | Upgrade connection to streaming mode |
|
||||||
| `events.replay` | Replay buffered events from the last N ms |
|
| `events.replay` | Replay buffered events from the last N ms |
|
||||||
| `emit` | Inject a synthetic event into the pipeline |
|
| `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.
|
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
|
||||||
|
|
||||||
|
|
@ -626,7 +546,7 @@ Available methods:
|
||||||
|
|
||||||
Bread is early-stage software. Contributions, issues, and feedback are welcome.
|
Bread is early-stage software. Contributions, issues, and feedback are welcome.
|
||||||
|
|
||||||
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem.
|
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API and module system.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
19
bakery.toml
Normal file
19
bakery.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-cli"
|
name = "bread-cli"
|
||||||
version = "1.0.0"
|
version = "0.6.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -13,7 +13,6 @@ path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
bread-sync = { path = "../bread-sync" }
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
@ -24,7 +23,6 @@ clap = { version = "4.5", features = ["derive"] }
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
|
||||||
flate2 = "1.0"
|
[dev-dependencies]
|
||||||
tar = "0.4"
|
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
mod modules_mgmt;
|
mod modules_mgmt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use bread_sync::{
|
|
||||||
config::{bread_config_dir, SyncConfig},
|
|
||||||
delegates, machine, packages, apply_import, stage_export, SyncRepo,
|
|
||||||
};
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -62,11 +58,6 @@ enum Commands {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
subcommand: ModulesCommand,
|
subcommand: ModulesCommand,
|
||||||
},
|
},
|
||||||
/// Manage sync (snapshot and restore system state)
|
|
||||||
Sync {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcommand: SyncCommand,
|
|
||||||
},
|
|
||||||
/// List available profiles
|
/// List available profiles
|
||||||
ProfileList,
|
ProfileList,
|
||||||
/// Activate a profile
|
/// Activate a profile
|
||||||
|
|
@ -91,9 +82,9 @@ enum Commands {
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum ModulesCommand {
|
enum ModulesCommand {
|
||||||
/// Install a module from a source
|
/// Install a module from a local directory
|
||||||
Install {
|
Install {
|
||||||
/// Source: github:user/repo[@ref] or /path/to/dir
|
/// Path to a local module directory
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
/// Remove an installed module
|
/// Remove an installed module
|
||||||
|
|
@ -105,66 +96,10 @@ enum ModulesCommand {
|
||||||
},
|
},
|
||||||
/// List all installed modules
|
/// List all installed modules
|
||||||
List,
|
List,
|
||||||
/// Update one or all installed modules
|
|
||||||
Update {
|
|
||||||
/// Module name (omit to update all)
|
|
||||||
name: Option<String>,
|
|
||||||
},
|
|
||||||
/// Show full manifest details for a module
|
/// Show full manifest details for a module
|
||||||
Info { name: String },
|
Info { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
enum SyncCommand {
|
|
||||||
/// Initialize sync for this machine
|
|
||||||
Init {
|
|
||||||
/// Git remote URL
|
|
||||||
#[arg(long)]
|
|
||||||
remote: Option<String>,
|
|
||||||
},
|
|
||||||
/// Snapshot and push current state
|
|
||||||
Push {
|
|
||||||
/// Custom commit message
|
|
||||||
#[arg(long)]
|
|
||||||
message: Option<String>,
|
|
||||||
},
|
|
||||||
/// 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-<machine>-<date>.tar.gz
|
|
||||||
#[arg(long, short)]
|
|
||||||
output: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// 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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -202,9 +137,6 @@ async fn main() -> Result<()> {
|
||||||
Commands::Modules { subcommand } => {
|
Commands::Modules { subcommand } => {
|
||||||
handle_modules_cmd(subcommand, &socket).await?;
|
handle_modules_cmd(subcommand, &socket).await?;
|
||||||
}
|
}
|
||||||
Commands::Sync { subcommand } => {
|
|
||||||
handle_sync_cmd(subcommand, &socket).await?;
|
|
||||||
}
|
|
||||||
Commands::ProfileList => {
|
Commands::ProfileList => {
|
||||||
let response = send_request(&socket, "profile.list", json!({})).await?;
|
let response = send_request(&socket, "profile.list", json!({})).await?;
|
||||||
print_json(&response)?;
|
print_json(&response)?;
|
||||||
|
|
@ -257,7 +189,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
ModulesCommand::Install { source } => {
|
ModulesCommand::Install { source } => {
|
||||||
let manifest = install_module(&source, &mods_dir).await?;
|
let manifest = install_module(&source, &mods_dir)?;
|
||||||
println!("installed {} v{}", manifest.name, manifest.version);
|
println!("installed {} v{}", manifest.name, manifest.version);
|
||||||
try_daemon_reload(socket).await;
|
try_daemon_reload(socket).await;
|
||||||
}
|
}
|
||||||
|
|
@ -312,39 +244,6 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ModulesCommand::Update { name } => {
|
|
||||||
let targets: Vec<_> = if let Some(n) = name {
|
|
||||||
vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?]
|
|
||||||
} else {
|
|
||||||
modules_mgmt::list_modules(&mods_dir)?
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut updated_any = false;
|
|
||||||
for manifest in targets {
|
|
||||||
if manifest.source.starts_with("github:") {
|
|
||||||
let old_ver = manifest.version.clone();
|
|
||||||
let new_manifest = install_module(&manifest.source, &mods_dir).await?;
|
|
||||||
if new_manifest.version == old_ver {
|
|
||||||
println!("{} already up to date", manifest.name);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"updated {} v{} → v{}",
|
|
||||||
manifest.name, old_ver, new_manifest.version
|
|
||||||
);
|
|
||||||
updated_any = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"cannot update local module '{}' — reinstall manually",
|
|
||||||
manifest.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if updated_any {
|
|
||||||
try_daemon_reload(socket).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModulesCommand::Info { name } => {
|
ModulesCommand::Info { name } => {
|
||||||
let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?;
|
let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?;
|
||||||
let status = match send_request(socket, "modules.list", json!({})).await {
|
let status = match send_request(socket, "modules.list", json!({})).await {
|
||||||
|
|
@ -371,75 +270,13 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn install_module(
|
fn install_module(
|
||||||
source: &str,
|
source: &str,
|
||||||
mods_dir: &std::path::Path,
|
mods_dir: &std::path::Path,
|
||||||
) -> Result<modules_mgmt::ModuleManifest> {
|
) -> Result<modules_mgmt::ModuleManifest> {
|
||||||
match modules_mgmt::parse_source(source)? {
|
let path = modules_mgmt::parse_source(source)?;
|
||||||
modules_mgmt::InstallSource::LocalPath(path) => {
|
|
||||||
modules_mgmt::install_from_local(&path, source, mods_dir)
|
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<modules_mgmt::ModuleManifest> {
|
|
||||||
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.
|
/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable.
|
||||||
async fn try_daemon_reload(socket: &Path) {
|
async fn try_daemon_reload(socket: &Path) {
|
||||||
|
|
@ -451,576 +288,6 @@ async fn try_daemon_reload(socket: &Path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sync subcommands
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> {
|
|
||||||
let cfg_dir = bread_config_dir();
|
|
||||||
|
|
||||||
match cmd {
|
|
||||||
SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?,
|
|
||||||
SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?,
|
|
||||||
SyncCommand::Pull { install_packages } => {
|
|
||||||
cmd_sync_pull(&cfg_dir, install_packages, socket).await?
|
|
||||||
}
|
|
||||||
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
|
|
||||||
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?,
|
|
||||||
SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?,
|
|
||||||
SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?,
|
|
||||||
SyncCommand::Import { from, install_packages, no_clone_repos, yes } => {
|
|
||||||
cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> 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<String> = 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<String>) -> 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<PathBuf>) -> 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<flate2::write::GzEncoder<std::fs::File>>,
|
|
||||||
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<PathBuf> {
|
|
||||||
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<SyncConfig> {
|
|
||||||
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)
|
// Helpers (shared with original commands)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -15,44 +15,31 @@ pub struct ModuleManifest {
|
||||||
pub installed_at: String,
|
pub installed_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed install source.
|
/// Resolve a module source string to a local directory path.
|
||||||
pub enum InstallSource {
|
///
|
||||||
GitHub {
|
/// Only local paths are accepted. Remote fetching (`github:user/repo`) was
|
||||||
user: String,
|
/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with
|
||||||
repo: String,
|
/// full `bread.exec()` privileges as the user. Installing a remote module now
|
||||||
git_ref: Option<String>,
|
/// requires cloning it yourself, so the review step stays in the user's hands.
|
||||||
},
|
pub fn parse_source(source: &str) -> Result<PathBuf> {
|
||||||
LocalPath(PathBuf),
|
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('/')
|
||||||
/// Parse a source string into an `InstallSource`.
|
|
||||||
pub fn parse_source(source: &str) -> Result<InstallSource> {
|
|
||||||
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("../")
|
|| source.starts_with("../")
|
||||||
|| source.starts_with('~')
|
|| source.starts_with('~')
|
||||||
{
|
{
|
||||||
let expanded = bread_sync::config::expand_path(source);
|
Ok(bread_shared::expand_path(source))
|
||||||
Ok(InstallSource::LocalPath(expanded))
|
|
||||||
} else {
|
} else {
|
||||||
bail!(
|
bail!(
|
||||||
"bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path",
|
"bread: invalid module source '{}'. Provide an absolute or relative \
|
||||||
|
path to a local module directory",
|
||||||
source
|
source
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "1.0.0"
|
version = "0.6.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -89,11 +89,53 @@ pub fn now_unix_ms() -> u64 {
|
||||||
.as_millis() as 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn adapter_source_serializes_as_snake_case() {
|
fn adapter_source_serializes_as_snake_case() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
36
bread-sync/EXTRACTION.md
Normal file
36
bread-sync/EXTRACTION.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# bread-sync — slated for extraction
|
||||||
|
|
||||||
|
This crate is **no longer part of the `bread` workspace**. It is parked here
|
||||||
|
pending extraction into its own standalone project.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`bread`'s architecture deliberately scopes itself to a reactive automation
|
||||||
|
fabric — see the Non-Goals in `Overview.md`. State/dotfile synchronization
|
||||||
|
across machines is explicitly *out* of that scope. `bread-sync` grew into a
|
||||||
|
git-backed snapshot/restore + package + delegate-path manager, which is a
|
||||||
|
genuinely useful tool but a different product with a different lifecycle. It
|
||||||
|
was the one component pulling `bread`'s scope discipline out of shape, so it
|
||||||
|
is being spun out rather than removed (the code is good; it just doesn't
|
||||||
|
belong in this repo).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Removed from the root `Cargo.toml` workspace (`members` → `exclude`).
|
||||||
|
- The `bread sync …` CLI subcommands have been removed from `bread-cli`.
|
||||||
|
- The `sync.status` IPC method and its integration tests have been removed
|
||||||
|
from `breadd`.
|
||||||
|
- No code in `bread`/`breadd`/`bread-cli` depends on this crate anymore.
|
||||||
|
|
||||||
|
## For whoever extracts it (name polls are open)
|
||||||
|
|
||||||
|
1. Move this directory into the new repository.
|
||||||
|
2. It inherited workspace dependencies (`serde`, `git2`, `dirs`, `chrono`,
|
||||||
|
`tempfile`, `glob`, …). Pin concrete versions in its own `Cargo.toml`;
|
||||||
|
`*.workspace = true` will not resolve outside this workspace.
|
||||||
|
3. The only helper that had to leave this crate is `config::expand_path`,
|
||||||
|
which moved to `bread-shared::expand_path` because non-sync code (the
|
||||||
|
module installer) needed it. Reintroduce a local copy in the new project
|
||||||
|
so it no longer depends on `bread-shared`.
|
||||||
|
4. Re-add the `bread sync` UX as a standalone binary, or as a `breadd` IPC
|
||||||
|
client, in the new project — not here.
|
||||||
|
|
@ -120,18 +120,22 @@ static DEFAULT_EXCLUDES: &[&str] = &[
|
||||||
|
|
||||||
/// Directories skipped when searching for git repos.
|
/// Directories skipped when searching for git repos.
|
||||||
static GIT_SKIP_DIRS: &[&str] = &[
|
static GIT_SKIP_DIRS: &[&str] = &[
|
||||||
".local", "Nextcloud", "target", "node_modules", "__pycache__",
|
".local",
|
||||||
".cache", "snap", "flatpak", "@girs", "Steam",
|
"Nextcloud",
|
||||||
|
"target",
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".cache",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"@girs",
|
||||||
|
"Steam",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── stage_export ────────────────────────────────────────────────────────────
|
// ── stage_export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Build a self-contained snapshot directory at `staging`.
|
/// Build a self-contained snapshot directory at `staging`.
|
||||||
pub fn stage_export(
|
pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Result<ExportManifest> {
|
||||||
cfg_dir: &Path,
|
|
||||||
config: &SyncConfig,
|
|
||||||
staging: &Path,
|
|
||||||
) -> Result<ExportManifest> {
|
|
||||||
fs::create_dir_all(staging)?;
|
fs::create_dir_all(staging)?;
|
||||||
|
|
||||||
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
|
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
@ -238,8 +242,7 @@ pub fn stage_export(
|
||||||
let fonts_src = expand_path("~/.local/share/fonts");
|
let fonts_src = expand_path("~/.local/share/fonts");
|
||||||
let fonts_dst = staging.join("local-fonts");
|
let fonts_dst = staging.join("local-fonts");
|
||||||
if fonts_src.exists() {
|
if fonts_src.exists() {
|
||||||
sync_dir(&fonts_src, &fonts_dst, &excludes)
|
sync_dir(&fonts_src, &fonts_dst, &excludes).context("failed to snapshot fonts")?;
|
||||||
.context("failed to snapshot fonts")?;
|
|
||||||
path_map.push(PathRecord {
|
path_map.push(PathRecord {
|
||||||
staging: "local-fonts".to_string(),
|
staging: "local-fonts".to_string(),
|
||||||
original: "~/.local/share/fonts".to_string(),
|
original: "~/.local/share/fonts".to_string(),
|
||||||
|
|
@ -292,9 +295,7 @@ pub fn stage_export(
|
||||||
match packages::snapshot(manager, &dest_file) {
|
match packages::snapshot(manager, &dest_file) {
|
||||||
Ok(true) => included_managers.push(manager.clone()),
|
Ok(true) => included_managers.push(manager.clone()),
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => eprintln!(
|
Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"),
|
||||||
"bread: warning: package snapshot for {manager} failed: {e}"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,10 +308,18 @@ pub fn stage_export(
|
||||||
// 11. Git repositories — find all repos with a remote, commit+push each
|
// 11. Git repositories — find all repos with a remote, commit+push each
|
||||||
let nc_dirs = nextcloud_sync_dirs(&home);
|
let nc_dirs = nextcloud_sync_dirs(&home);
|
||||||
if !nc_dirs.is_empty() {
|
if !nc_dirs.is_empty() {
|
||||||
let labels: Vec<_> = nc_dirs.iter()
|
let labels: Vec<_> = nc_dirs
|
||||||
.map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string()))
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
p.strip_prefix(&home)
|
||||||
|
.map(|r| format!("~/{}", r.display()))
|
||||||
|
.unwrap_or_else(|_| p.display().to_string())
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", "));
|
eprintln!(
|
||||||
|
"bread: skipping Nextcloud-tracked folders: {}",
|
||||||
|
labels.join(", ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let repos = find_git_repos(&home);
|
let repos = find_git_repos(&home);
|
||||||
commit_and_push_repos(&repos, &home);
|
commit_and_push_repos(&repos, &home);
|
||||||
|
|
@ -565,10 +574,7 @@ fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) {
|
||||||
.output();
|
.output();
|
||||||
match push {
|
match push {
|
||||||
Ok(o) if o.status.success() => eprintln!("ok"),
|
Ok(o) if o.status.success() => eprintln!("ok"),
|
||||||
Ok(o) => eprintln!(
|
Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()),
|
||||||
"failed: {}",
|
|
||||||
String::from_utf8_lossy(&o.stderr).trim()
|
|
||||||
),
|
|
||||||
Err(e) => eprintln!("failed: {}", e),
|
Err(e) => eprintln!("failed: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +617,15 @@ fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
|
||||||
walk_repos(home, home, 0, 1, &mut repos, &nc_dirs);
|
walk_repos(home, home, 0, 1, &mut repos, &nc_dirs);
|
||||||
|
|
||||||
// Deeper search in common project directories
|
// 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);
|
let p = home.join(subdir);
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs);
|
walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs);
|
||||||
|
|
@ -630,7 +644,14 @@ fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
|
||||||
repos
|
repos
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec<GitRepoRecord>, nc_dirs: &[PathBuf]) {
|
fn walk_repos(
|
||||||
|
dir: &Path,
|
||||||
|
home: &Path,
|
||||||
|
depth: u32,
|
||||||
|
max_depth: u32,
|
||||||
|
repos: &mut Vec<GitRepoRecord>,
|
||||||
|
nc_dirs: &[PathBuf],
|
||||||
|
) {
|
||||||
// Skip anything inside a Nextcloud sync root
|
// Skip anything inside a Nextcloud sync root
|
||||||
if nc_dirs.iter().any(|nc| dir.starts_with(nc)) {
|
if nc_dirs.iter().any(|nc| dir.starts_with(nc)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -655,7 +676,11 @@ fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut V
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|_| dir.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)
|
return; // don't recurse into git repos (skip submodules)
|
||||||
|
|
@ -700,7 +725,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> {
|
||||||
let cargo_file = packages_dir.join("cargo.txt");
|
let cargo_file = packages_dir.join("cargo.txt");
|
||||||
if cargo_file.exists() {
|
if cargo_file.exists() {
|
||||||
for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) {
|
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");
|
let pip_file = packages_dir.join("pip.txt");
|
||||||
|
|
@ -713,7 +740,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> {
|
||||||
let npm_file = packages_dir.join("npm.txt");
|
let npm_file = packages_dir.join("npm.txt");
|
||||||
if npm_file.exists() {
|
if npm_file.exists() {
|
||||||
for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) {
|
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(())
|
Ok(())
|
||||||
|
|
@ -787,7 +816,9 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String {
|
||||||
s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n");
|
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()) {
|
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()) {
|
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");
|
s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n");
|
||||||
|
|
@ -832,9 +863,7 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String {
|
||||||
if !parent.is_empty() {
|
if !parent.is_empty() {
|
||||||
s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n"));
|
s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n"));
|
||||||
}
|
}
|
||||||
s.push_str(&format!(
|
s.push_str(&format!("if [ ! -d \"{dest}/.git\" ]; then\n"));
|
||||||
"if [ ! -d \"{dest}/.git\" ]; then\n"
|
|
||||||
));
|
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
" git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n",
|
" git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n",
|
||||||
repo.path
|
repo.path
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadd"
|
name = "breadd"
|
||||||
version = "1.0.0"
|
version = "0.6.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
bread-sync = { path = "../bread-sync" }
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -267,32 +267,6 @@ impl Server {
|
||||||
"recent_errors": recent_errors,
|
"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::<toml::Value>().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" => {
|
"events.replay" => {
|
||||||
let since_ms = req
|
let since_ms = req
|
||||||
.params
|
.params
|
||||||
|
|
|
||||||
|
|
@ -873,7 +873,8 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
bread.set("module", module_fn)?;
|
bread.set("module", module_fn)?;
|
||||||
|
|
||||||
// bread.machine — machine name and tags from sync.toml
|
// bread.machine — hostname/tags; reads an optional, externally-managed
|
||||||
|
// ~/.config/bread/sync.toml if present (bread does not create it)
|
||||||
let machine_tbl = self.lua.create_table()?;
|
let machine_tbl = self.lua.create_table()?;
|
||||||
|
|
||||||
let name_fn = self
|
let name_fn = self
|
||||||
|
|
@ -947,9 +948,9 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
bluetooth_tbl.set("power", power_fn)?;
|
bluetooth_tbl.set("power", power_fn)?;
|
||||||
|
|
||||||
let powered_fn = self.lua.create_function(move |_lua, ()| {
|
let powered_fn = self
|
||||||
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
|
.lua
|
||||||
})?;
|
.create_function(move |_lua, ()| Ok(bluetooth_query(bluetooth_get_powered).ok()))?;
|
||||||
bluetooth_tbl.set("powered", powered_fn)?;
|
bluetooth_tbl.set("powered", powered_fn)?;
|
||||||
|
|
||||||
let connect_fn = self.lua.create_function(move |_lua, address: String| {
|
let connect_fn = self.lua.create_function(move |_lua, address: String| {
|
||||||
|
|
@ -983,7 +984,7 @@ impl LuaEngine {
|
||||||
bluetooth_tbl.set("scan", scan_fn)?;
|
bluetooth_tbl.set("scan", scan_fn)?;
|
||||||
|
|
||||||
let devices_fn = self.lua.create_function(move |lua, ()| {
|
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,
|
Ok(d) => d,
|
||||||
Err(_) => return Ok(Value::Nil),
|
Err(_) => return Ok(Value::Nil),
|
||||||
};
|
};
|
||||||
|
|
@ -2298,7 +2299,8 @@ where
|
||||||
.block_on(factory());
|
.block_on(factory());
|
||||||
let _ = tx.send(result);
|
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<String> {
|
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
|
||||||
|
|
@ -2392,7 +2394,11 @@ async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
|
||||||
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
|
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
|
||||||
let conn = zbus::Connection::system().await?;
|
let conn = zbus::Connection::system().await?;
|
||||||
let adapter = bluetooth_find_adapter(&conn).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(
|
conn.call_method(
|
||||||
Some("org.bluez"),
|
Some("org.bluez"),
|
||||||
adapter.as_str(),
|
adapter.as_str(),
|
||||||
|
|
@ -2429,7 +2435,7 @@ async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
|
||||||
> = msg.body()?;
|
> = msg.body()?;
|
||||||
|
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
for (_, interfaces) in &objects {
|
for interfaces in objects.values() {
|
||||||
if let Some(props) = interfaces.get("org.bluez.Device1") {
|
if let Some(props) = interfaces.get("org.bluez.Device1") {
|
||||||
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
|
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
devices.push(BluetoothDevice {
|
devices.push(BluetoothDevice {
|
||||||
|
|
|
||||||
|
|
@ -161,37 +161,49 @@ async fn modules_reload_succeeds() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sync_status_uninitialized_when_no_config() -> Result<()> {
|
async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> Result<()> {
|
||||||
let harness = TestHarness::spawn()?;
|
let harness = TestHarness::spawn()?;
|
||||||
harness.wait_until_ready().await?;
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
let result = harness.send_request("sync.status", json!({})).await?;
|
// Event emitted before any reload.
|
||||||
assert_eq!(
|
harness
|
||||||
result.get("initialized").and_then(Value::as_bool),
|
.send_request("emit", json!({"event": "bread.reload.before", "data": {}}))
|
||||||
Some(false)
|
.await?;
|
||||||
);
|
|
||||||
|
|
||||||
harness.shutdown();
|
// Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers,
|
||||||
Ok(())
|
// and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked
|
||||||
|
// dispatch, paused-and-never-resumed pipeline) is the regression this guards
|
||||||
|
// — the previous suite only checked a single happy-path reload.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let r = harness.send_request("modules.reload", json!({})).await?;
|
||||||
|
assert_eq!(r.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
// Daemon must still answer control requests after the reload storm.
|
||||||
async fn sync_status_reports_initialized_with_config() -> Result<()> {
|
let ping = harness.send_request("ping", json!({})).await?;
|
||||||
let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
|
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
harness.wait_until_ready().await?;
|
let health = harness.send_request("health", json!({})).await?;
|
||||||
|
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
|
|
||||||
let result = harness.send_request("sync.status", json!({})).await?;
|
// The pipeline must have resumed: an event emitted *after* the reloads
|
||||||
assert_eq!(
|
// still flows through normalization into the replay buffer.
|
||||||
result.get("initialized").and_then(Value::as_bool),
|
harness
|
||||||
Some(true)
|
.send_request("emit", json!({"event": "bread.reload.after", "data": {}}))
|
||||||
);
|
.await?;
|
||||||
assert_eq!(
|
sleep(Duration::from_millis(100)).await;
|
||||||
result.get("machine").and_then(Value::as_str),
|
|
||||||
Some("myhost")
|
let replay = harness
|
||||||
);
|
.send_request("events.replay", json!({"since_ms": 30_000}))
|
||||||
assert_eq!(
|
.await?;
|
||||||
result.get("remote").and_then(Value::as_str),
|
let names: Vec<&str> = replay
|
||||||
Some("git@example.com:user/repo.git")
|
.as_array()
|
||||||
|
.expect("replay result should be array")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.get("event").and_then(Value::as_str))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"bread.reload.after"),
|
||||||
|
"event pipeline did not resume after reload; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
harness.shutdown();
|
harness.shutdown();
|
||||||
|
|
@ -385,14 +397,6 @@ struct TestHarness {
|
||||||
|
|
||||||
impl TestHarness {
|
impl TestHarness {
|
||||||
fn spawn() -> Result<Self> {
|
fn spawn() -> Result<Self> {
|
||||||
Self::spawn_inner(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result<Self> {
|
|
||||||
Self::spawn_inner(Some((machine.to_string(), remote_url.to_string())))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_inner(sync_config: Option<(String, String)>) -> Result<Self> {
|
|
||||||
let temp = tempfile::tempdir()?;
|
let temp = tempfile::tempdir()?;
|
||||||
let runtime_dir = temp.path().join("runtime");
|
let runtime_dir = temp.path().join("runtime");
|
||||||
let config_home = temp.path().join("config");
|
let config_home = temp.path().join("config");
|
||||||
|
|
@ -433,21 +437,6 @@ enabled = false
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Some((machine, remote_url)) = sync_config {
|
|
||||||
let sync_toml = format!(
|
|
||||||
r#"
|
|
||||||
[remote]
|
|
||||||
url = "{remote_url}"
|
|
||||||
branch = "main"
|
|
||||||
|
|
||||||
[machine]
|
|
||||||
name = "{machine}"
|
|
||||||
tags = []
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
fs::write(bread_cfg.join("sync.toml"), sync_toml)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
||||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
pkgname=bread
|
pkgname=bread
|
||||||
pkgver=1.0.0
|
pkgver=0.6.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A reactive automation fabric for Linux desktops"
|
pkgdesc="A reactive automation fabric for Linux desktops"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Breadway/bread"
|
url="https://github.com/Breadway/bread"
|
||||||
license=('MIT')
|
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')
|
depends=('glibc' 'libgit2')
|
||||||
optdepends=(
|
optdepends=(
|
||||||
'libnotify: desktop notifications via bread.notify()'
|
'libnotify: desktop notifications via bread.notify()'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue