Compare commits

..

3 commits
v0.1.1 ... main

Author SHA1 Message Date
Breadway
4f4bb46eed Merge pull request #7 from Breadway/dev
Some checks failed
CI / build-and-test (ubuntu-latest, stable) (push) Failing after 6s
CI / build-and-test (macos-latest, stable) (push) Has been cancelled
Document sync export/import and update snapshot layout
2026-05-16 22:18:41 +08:00
Breadway
902e1555da Merge pull request #6 from Breadway/dev
Final Release of Version 1.0
2026-05-13 22:18:52 +08:00
Breadway
cbf42ce70f Merge pull request #5 from Breadway/dev
Enhance device normalization and classification with Lua support
2026-05-12 21:31:15 +08:00
19 changed files with 2010 additions and 383 deletions

View file

@ -2,31 +2,29 @@ name: CI
on: on:
push: push:
branches: [ master, dev ] branches: [ main ]
pull_request: pull_request:
branches: [ master, dev ] branches: [ main ]
jobs: jobs:
build-and-test: build-and-test:
runs-on: ubuntu-latest strategy:
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: stable toolchain: ${{ matrix.rust }}
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
@ -36,9 +34,9 @@ jobs:
- name: Package artifacts - name: Package artifacts
run: | run: |
mkdir -p dist 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 - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: bread-ubuntu-latest name: bread-${{ matrix.os }}
path: dist/*.tgz path: dist/*.tgz

View file

@ -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

884
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,6 @@ 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"

View file

@ -7,6 +7,7 @@
- [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)
@ -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 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
# Clone and review, then install from the local checkout # Install from GitHub (downloads and extracts the default branch tarball)
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi bread modules install github:someuser/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,6 +117,9 @@ 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
@ -129,10 +132,101 @@ 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 = "/home/you/src/bread-wifi" source = "github:someuser/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.
@ -302,13 +396,10 @@ 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 system hostname. If an external tool has written a Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized.
`~/.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 `[machine].tags` from `~/.config/bread/sync.toml` if that file Returns the tags array from `sync.toml`, or `{}` if sync is not initialized.
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.
@ -833,3 +924,4 @@ 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
View file

@ -45,6 +45,7 @@ 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
``` ```
@ -193,9 +194,26 @@ 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 /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 <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
``` ```
--- ---
@ -206,15 +224,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
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi # From GitHub (downloads latest release tarball)
# review ~/src/bread-wifi, then: bread modules install github:someuser/bread-wifi
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
@ -234,7 +252,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 = "/home/you/src/bread-wifi" source = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z" 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 ## Event reference
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`. Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
@ -417,7 +496,7 @@ end
### Machine and filesystem ### Machine and filesystem
```lua ```lua
-- Machine identity (system hostname) -- Machine identity (from sync.toml, falls back to 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")
@ -537,6 +616,7 @@ 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.
@ -546,7 +626,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 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.
--- ---

View file

@ -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",
]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bread-cli" name = "bread-cli"
version = "0.6.1" version = "1.0.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@ -13,6 +13,7 @@ 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
@ -23,6 +24,7 @@ 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"] }
[dev-dependencies] flate2 = "1.0"
tar = "0.4"
tempfile.workspace = true tempfile.workspace = true

View file

@ -1,6 +1,10 @@
mod modules_mgmt; 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 clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -58,6 +62,11 @@ 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
@ -82,9 +91,9 @@ enum Commands {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum ModulesCommand { enum ModulesCommand {
/// Install a module from a local directory /// Install a module from a source
Install { Install {
/// Path to a local module directory /// Source: github:user/repo[@ref] or /path/to/dir
source: String, source: String,
}, },
/// Remove an installed module /// Remove an installed module
@ -96,10 +105,66 @@ 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();
@ -137,6 +202,9 @@ 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)?;
@ -189,7 +257,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)?; let manifest = install_module(&source, &mods_dir).await?;
println!("installed {} v{}", manifest.name, manifest.version); println!("installed {} v{}", manifest.name, manifest.version);
try_daemon_reload(socket).await; 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 } => { 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 {
@ -270,12 +371,74 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
Ok(()) Ok(())
} }
fn install_module( async 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> {
let path = modules_mgmt::parse_source(source)?; match modules_mgmt::parse_source(source)? {
modules_mgmt::install_from_local(&path, source, mods_dir) 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<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.
@ -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<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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -15,31 +15,44 @@ pub struct ModuleManifest {
pub installed_at: String, pub installed_at: String,
} }
/// Resolve a module source string to a local directory path. /// Parsed install source.
/// pub enum InstallSource {
/// Only local paths are accepted. Remote fetching (`github:user/repo`) was GitHub {
/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with user: String,
/// full `bread.exec()` privileges as the user. Installing a remote module now repo: String,
/// requires cloning it yourself, so the review step stays in the user's hands. git_ref: Option<String>,
pub fn parse_source(source: &str) -> Result<PathBuf> { },
if source.starts_with("github:") || source.starts_with("git:") { LocalPath(PathBuf),
bail!( }
"bread: remote module installation has been removed for security \
(it ran unreviewed third-party Lua with full exec privileges). \ /// Parse a source string into an `InstallSource`.
Clone the repository yourself, review it, then run \ pub fn parse_source(source: &str) -> Result<InstallSource> {
'bread modules install /path/to/checkout'" if let Some(rest) = source.strip_prefix("github:") {
); let (repo_part, ref_part) = rest
} .split_once('@')
if source.starts_with('/') .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('~')
{ {
Ok(bread_shared::expand_path(source)) let expanded = bread_sync::config::expand_path(source);
Ok(InstallSource::LocalPath(expanded))
} else { } else {
bail!( bail!(
"bread: invalid module source '{}'. Provide an absolute or relative \ "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path",
path to a local module directory",
source source
) )
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bread-shared" name = "bread-shared"
version = "0.6.1" version = "1.0.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -89,53 +89,11 @@ 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!(

View file

@ -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.

View file

@ -64,48 +64,48 @@ pub struct ExportManifest {
/// Config directories always included in the export (if they exist on disk). /// Config directories always included in the export (if they exist on disk).
static BUILTIN_CONFIGS: &[(&str, &str)] = &[ static BUILTIN_CONFIGS: &[(&str, &str)] = &[
("hypr", "~/.config/hypr"), ("hypr", "~/.config/hypr"),
("fish", "~/.config/fish"), ("fish", "~/.config/fish"),
("kitty", "~/.config/kitty"), ("kitty", "~/.config/kitty"),
("nvim", "~/.config/nvim"), ("nvim", "~/.config/nvim"),
("ags", "~/.config/ags"), ("ags", "~/.config/ags"),
("wofi", "~/.config/wofi"), ("wofi", "~/.config/wofi"),
("waybar", "~/.config/waybar"), ("waybar", "~/.config/waybar"),
("dunst", "~/.config/dunst"), ("dunst", "~/.config/dunst"),
("mako", "~/.config/mako"), ("mako", "~/.config/mako"),
("hyprlock", "~/.config/hyprlock"), ("hyprlock", "~/.config/hyprlock"),
("hyprpaper", "~/.config/hyprpaper"), ("hyprpaper", "~/.config/hyprpaper"),
("swaylock", "~/.config/swaylock"), ("swaylock", "~/.config/swaylock"),
("wlogout", "~/.config/wlogout"), ("wlogout", "~/.config/wlogout"),
("swappy", "~/.config/swappy"), ("swappy", "~/.config/swappy"),
("btop", "~/.config/btop"), ("btop", "~/.config/btop"),
("waypaper", "~/.config/waypaper"), ("waypaper", "~/.config/waypaper"),
("wal", "~/.config/wal"), ("wal", "~/.config/wal"),
("gtk-3.0", "~/.config/gtk-3.0"), ("gtk-3.0", "~/.config/gtk-3.0"),
("gtk-4.0", "~/.config/gtk-4.0"), ("gtk-4.0", "~/.config/gtk-4.0"),
("keyd", "~/.config/keyd"), ("keyd", "~/.config/keyd"),
("autostart", "~/.config/autostart"), ("autostart", "~/.config/autostart"),
]; ];
/// Standalone dotfiles captured as individual files: (staging-name, source-path). /// Standalone dotfiles captured as individual files: (staging-name, source-path).
static BUILTIN_DOTFILES: &[(&str, &str)] = &[ static BUILTIN_DOTFILES: &[(&str, &str)] = &[
(".gitconfig", "~/.gitconfig"), (".gitconfig", "~/.gitconfig"),
("user-dirs.dirs", "~/.config/user-dirs.dirs"), ("user-dirs.dirs", "~/.config/user-dirs.dirs"),
("mimeapps.list", "~/.config/mimeapps.list"), ("mimeapps.list", "~/.config/mimeapps.list"),
("ssh_config", "~/.ssh/config"), ("ssh_config", "~/.ssh/config"),
(".zshrc", "~/.zshrc"), (".zshrc", "~/.zshrc"),
(".zprofile", "~/.zprofile"), (".zprofile", "~/.zprofile"),
(".zshenv", "~/.zshenv"), (".zshenv", "~/.zshenv"),
]; ];
/// System-level directories. World-readable ones are copied directly; /// System-level directories. World-readable ones are copied directly;
/// root-only ones (networkmanager, bluetooth) require running with sudo. /// root-only ones (networkmanager, bluetooth) require running with sudo.
static SYSTEM_PATHS: &[(&str, &str)] = &[ static SYSTEM_PATHS: &[(&str, &str)] = &[
("udev", "/etc/udev/rules.d"), ("udev", "/etc/udev/rules.d"),
("modprobe", "/etc/modprobe.d"), ("modprobe", "/etc/modprobe.d"),
("sysctl", "/etc/sysctl.d"), ("sysctl", "/etc/sysctl.d"),
("networkmanager", "/etc/NetworkManager/system-connections"), ("networkmanager", "/etc/NetworkManager/system-connections"),
("bluetooth", "/var/lib/bluetooth"), ("bluetooth", "/var/lib/bluetooth"),
]; ];
/// Directories excluded from every recursive copy. /// Directories excluded from every recursive copy.
@ -120,22 +120,18 @@ 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", ".local", "Nextcloud", "target", "node_modules", "__pycache__",
"Nextcloud", ".cache", "snap", "flatpak", "@girs", "Steam",
"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(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Result<ExportManifest> { pub fn stage_export(
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();
@ -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_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).context("failed to snapshot fonts")?; sync_dir(&fonts_src, &fonts_dst, &excludes)
.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(),
@ -295,7 +292,9 @@ pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Resu
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!("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 // 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 let labels: Vec<_> = nc_dirs.iter()
.iter() .map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string()))
.map(|p| {
p.strip_prefix(&home)
.map(|r| format!("~/{}", r.display()))
.unwrap_or_else(|_| p.display().to_string())
})
.collect(); .collect();
eprintln!( eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", "));
"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);
@ -574,7 +565,10 @@ 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!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), Ok(o) => eprintln!(
"failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("failed: {}", e), Err(e) => eprintln!("failed: {}", e),
} }
} }
@ -617,15 +611,7 @@ 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 &[ for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] {
"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);
@ -644,14 +630,7 @@ fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
repos repos
} }
fn walk_repos( fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec<GitRepoRecord>, nc_dirs: &[PathBuf]) {
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;
@ -676,11 +655,7 @@ fn walk_repos(
.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 { repos.push(GitRepoRecord { path: rel, remote, branch });
path: rel,
remote,
branch,
});
} }
} }
return; // don't recurse into git repos (skip submodules) 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"); 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") let _ = std::process::Command::new("cargo").args(["install", &pkg]).status();
.args(["install", &pkg])
.status();
} }
} }
let pip_file = packages_dir.join("pip.txt"); 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"); 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") let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status();
.args(["install", "-g", &pkg])
.status();
} }
} }
Ok(()) 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"); 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( s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n");
"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");
@ -863,7 +832,9 @@ 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!("if [ ! -d \"{dest}/.git\" ]; then\n")); s.push_str(&format!(
"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

View file

@ -1,10 +1,11 @@
[package] [package]
name = "breadd" name = "breadd"
version = "0.6.1" version = "1.0.0"
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

View file

@ -267,6 +267,32 @@ 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

View file

@ -873,8 +873,7 @@ impl LuaEngine {
})?; })?;
bread.set("module", module_fn)?; bread.set("module", module_fn)?;
// bread.machine — hostname/tags; reads an optional, externally-managed // bread.machine — machine name and tags from sync.toml
// ~/.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
@ -948,9 +947,9 @@ impl LuaEngine {
})?; })?;
bluetooth_tbl.set("power", power_fn)?; bluetooth_tbl.set("power", power_fn)?;
let powered_fn = self let powered_fn = self.lua.create_function(move |_lua, ()| {
.lua Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
.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| {
@ -984,7 +983,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),
}; };
@ -2299,8 +2298,7 @@ where
.block_on(factory()); .block_on(factory());
let _ = tx.send(result); let _ = tx.send(result);
}); });
rx.recv() rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
.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> {
@ -2394,11 +2392,7 @@ 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 { let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
"StartDiscovery"
} else {
"StopDiscovery"
};
conn.call_method( conn.call_method(
Some("org.bluez"), Some("org.bluez"),
adapter.as_str(), adapter.as_str(),
@ -2435,7 +2429,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.values() { for (_, interfaces) in &objects {
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 {

View file

@ -161,49 +161,37 @@ async fn modules_reload_succeeds() -> Result<()> {
} }
#[tokio::test] #[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()?; let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?; harness.wait_until_ready().await?;
// Event emitted before any reload. let result = harness.send_request("sync.status", json!({})).await?;
harness assert_eq!(
.send_request("emit", json!({"event": "bread.reload.before", "data": {}})) result.get("initialized").and_then(Value::as_bool),
.await?; Some(false)
);
// Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers, harness.shutdown();
// and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked Ok(())
// 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));
}
// Daemon must still answer control requests after the reload storm. #[tokio::test]
let ping = harness.send_request("ping", json!({})).await?; async fn sync_status_reports_initialized_with_config() -> Result<()> {
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
let health = harness.send_request("health", json!({})).await?; harness.wait_until_ready().await?;
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
// The pipeline must have resumed: an event emitted *after* the reloads let result = harness.send_request("sync.status", json!({})).await?;
// still flows through normalization into the replay buffer. assert_eq!(
harness result.get("initialized").and_then(Value::as_bool),
.send_request("emit", json!({"event": "bread.reload.after", "data": {}})) Some(true)
.await?; );
sleep(Duration::from_millis(100)).await; assert_eq!(
result.get("machine").and_then(Value::as_str),
let replay = harness Some("myhost")
.send_request("events.replay", json!({"since_ms": 30_000})) );
.await?; assert_eq!(
let names: Vec<&str> = replay result.get("remote").and_then(Value::as_str),
.as_array() Some("git@example.com:user/repo.git")
.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();
@ -397,6 +385,14 @@ 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");
@ -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 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)

View file

@ -1,7 +1,7 @@
# Maintainer: Breadway <rileyhorsham@gmail.com> # Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bread pkgname=bread
pkgver=0.6.0 pkgver=1.0.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')