From 5adcfb3854ee31379fb3925004c60bbcbbcaec81 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 20:56:10 +0800 Subject: [PATCH 1/4] Begin Implementing V2 features --- .github/workflows/ci.yml | 42 -- .gitignore | 3 +- CLAUDE_SPEC.md | 604 +++++++++++++++++++ Cargo.lock | 1090 ++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 + bread-cli/src/main.rs | 904 +++++++++++++++++++++++++++-- bread-sync/Cargo.toml | 19 + bread-sync/README.md | 10 + bread-sync/src/config.rs | 124 ++++ bread-sync/src/delegates.rs | 205 +++++++ bread-sync/src/git.rs | 227 ++++++++ bread-sync/src/lib.rs | 10 + bread-sync/src/machine.rs | 102 ++++ bread-sync/src/packages.rs | 137 +++++ bread-sync/tests/sync.rs | 1 + breadd/src/adapters/udev.rs | 59 +- 18 files changed, 3433 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE_SPEC.md create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7409b04..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build-and-test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - rust: [stable] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: ${{ matrix.rust }} - - name: Cargo cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - . -> target - - name: Build - run: cargo build --workspace --verbose - - name: Run tests - run: cargo test --workspace --verbose - - name: Build release - run: cargo build --workspace --release - - name: Package artifacts - run: | - mkdir -p dist - tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: bread-${{ matrix.os }} - path: dist/*.tgz diff --git a/.gitignore b/.gitignore index 9472698..6902b11 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github/ \ No newline at end of file +.github/workflows/ci.yml +.github \ No newline at end of file diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md new file mode 100644 index 0000000..2a2d1df --- /dev/null +++ b/CLAUDE_SPEC.md @@ -0,0 +1,604 @@ +# Bread — Sync & Module System Implementation Spec +### Instructions for Claude Code + +This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. + +--- + +## Context + +Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: + +- `breadd/` — the runtime daemon (Rust + Lua via mlua) +- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) +- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. + +The two things being added in this iteration: + +1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs +2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote + +--- + +## Part 1: Module System + +### What a module is + +A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← module manifest (required) + ├── init.lua ← entry point (required) + └── lib/ ← optional support files +``` + +### Module manifest (`bread.module.toml`) + +Every installed module must have a manifest: + +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" # where it was installed from +installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install +``` + +All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. + +### Install sources + +The module installer must support these source formats: + +``` +github:user/repo # installs default branch +github:user/repo@v1.2.0 # installs specific tag +github:user/repo@abc1234 # installs specific commit +/path/to/local/dir # installs from local directory (copies it) +``` + +Anything else is an error with a clear message. + +### New Cargo dependencies allowed + +Add to `bread-cli/Cargo.toml` as needed: +- `git2 = "0.18"` for Git operations +- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API +- `flate2`, `tar` for archive extraction + +Add to `breadd/Cargo.toml` as needed: +- `git2 = "0.18"` +- `toml = "0.8"` (already present) + +### CLI commands to implement + +All module commands live under `bread modules`: + +``` +bread modules install Install a module +bread modules remove Remove an installed module +bread modules list List installed modules with name, version, status +bread modules update Update all installed modules to latest +bread modules update Update a specific module +bread modules info Show full manifest details for a module +``` + +**`bread modules install `** + +1. Parse the source string. +2. For `github:user/repo[@ref]`: + - Use the GitHub API to resolve the ref (or default branch if none specified). + - Download the repository archive as a `.tar.gz`. + - Extract to a temp directory. + - Verify a `bread.module.toml` exists at the root. If not, error cleanly. + - Copy the module directory to `~/.config/bread/modules//`. + - Write `installed_at` into the manifest. +3. For local paths: + - Verify the path exists and contains `bread.module.toml`. + - Copy to `~/.config/bread/modules//`. + - Write `installed_at`. +4. Print `installed v` on success. +5. Tell the daemon to reload via IPC (`modules.reload`) after install. + +**`bread modules remove `** + +1. Find `~/.config/bread/modules//`. +2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. +3. Delete the directory. +4. Tell the daemon to reload via IPC. +5. Print `removed `. + +**`bread modules list`** + +Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: + +``` + wifi 1.0.0 loaded github:someuser/bread-wifi + redox 0.3.1 loaded github:breadway/bread-redox + broken-mod 0.1.0 error /home/user/local-module +``` + +Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. + +**`bread modules update [name]`** + +1. Read `bread.module.toml` for each module to update. +2. If `source` starts with `github:`, re-run the install for that source. +3. If `source` is a local path, error with `cannot update local module — reinstall manually`. +4. Print `updated v → v` or ` already up to date`. + +**`bread modules info `** + +Print full manifest contents plus daemon-reported status. Example: + +``` +name: wifi +version: 1.0.0 +description: WiFi management for Bread +author: someuser +source: github:someuser/bread-wifi +installed_at: 2026-05-11T09:00:00Z +status: loaded +``` + +### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events + +In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: + +```rust +"vendor_id": prop_str(&event, "ID_VENDOR_ID"), +"product_id": prop_str(&event, "ID_MODEL_ID"), +``` + +These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. + +--- + +## Part 2: Sync System + +### Overview + +Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: + +1. **Bread config** — everything in `~/.config/bread/` (always included) +2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) +3. **Package manifest** — lists of explicitly-installed packages per package manager +4. **Machine profile** — machine name and tags for machine-aware config + +Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. + +### New crate: `bread-sync` + +Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. + +``` +bread-sync/ +├── Cargo.toml +└── src/ + ├── lib.rs + ├── config.rs ← SyncConfig type, load/save + ├── git.rs ← Git operations via git2 + ├── packages.rs ← Package manifest generation + ├── delegates.rs ← Config file delegation + └── machine.rs ← Machine profile +``` + +`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. + +### Sync configuration (`~/.config/bread/sync.toml`) + +This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. + +```toml +[remote] +url = "git@github.com:user/bread-sync.git" # required, set by bread sync init +branch = "main" # default: "main" + +[machine] +name = "laptop" # required, set by bread sync init +tags = ["mobile", "battery", "single-monitor"] # user-defined, optional + +[packages] +enabled = true +managers = ["pacman", "pip", "npm"] # which package managers to snapshot + +[delegates] +# Additional config directories to include in sync. +# ~/.config/bread/ is always included and does not need to be listed here. +include = [ + "~/.config/nvim", + "~/.config/fish", + "~/.config/kitty", +] +exclude = [ + "**/.git", + "**/node_modules", + "**/__pycache__", + "**/*.log", + "**/*.cache", + "~/.config/nvim/.repro", +] +``` + +All paths support `~` expansion. Globs in `exclude` use standard glob syntax. + +### Sync repo layout + +The Git repository managed by Bread has this structure: + +``` +/ +├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) +├── configs/ +│ ├── nvim/ ← copy of ~/.config/nvim/ +│ ├── fish/ ← copy of ~/.config/fish/ +│ └── kitty/ ← copy of ~/.config/kitty/ +├── packages/ +│ ├── pacman.txt ← output of `pacman -Qe` +│ ├── pip.txt ← output of `pip list --user --format=freeze` +│ └── npm.txt ← output of `npm list -g --depth=0` +├── machines/ +│ └── laptop.toml ← machine profile for this machine +└── .bread-sync ← sync metadata (not committed to Git) +``` + +`machines/.toml` contains: + +```toml +name = "laptop" +hostname = "breadway-laptop" # auto-detected via gethostname +tags = ["mobile", "battery", "single-monitor"] +last_sync = "2026-05-11T09:15:00Z" +``` + +### CLI commands to implement + +All sync commands live under `bread sync`: + +``` +bread sync init [--remote ] Initialize sync for this machine +bread sync push [--message ] Snapshot and push current state +bread sync pull Pull and apply latest state +bread sync status Show what has changed since last push +bread sync diff Show file-level diff vs remote +bread sync machines List known machines from sync repo +``` + +**`bread sync init [--remote ]`** + +1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` +2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. +3. Prompt: `Machine name [laptop]: ` (default: hostname). +4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. +5. Create `~/.config/bread/sync.toml` with the provided values. +6. If the remote is a URL (not a local path), check if the repo exists: + - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). + - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. +7. Print setup summary. + +**`bread sync push [--message ]`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. +3. Snapshot each section: + - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) + - For each path in `delegates.include`: copy to `/configs//` + - If `packages.enabled`: run package manager queries and write to `/packages/` + - Write `/machines/.toml` +4. Stage all changes (`git add -A`). +5. If there are no changes, print `nothing to push — already up to date` and exit. +6. Commit with message: `sync: ` or the user-provided `--message`. +7. Push to remote. +8. Print a summary of what was snapshotted. + +**`bread sync pull`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Pull from remote (fetch + merge or rebase — use merge, simpler). +3. Apply each section in order: + - Copy `/bread/` → `~/.config/bread/` (same rsync-style) + - For each path in `delegates.include` that exists in `/configs/`: copy back + - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) +4. Tell the daemon to reload via IPC (`modules.reload`) after applying. +5. Print a summary of what was applied. + +**Package install on pull** (only when `--install-packages` is explicitly passed): + +- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` +- `pip.txt` → `pip install --user -r pip.txt` +- `npm.txt` → parse package names and run `npm install -g` + +Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. + +**`bread sync status`** + +1. Load sync config and local repo. +2. Pull remote refs without merging (fetch only). +3. Compare working tree to last commit and compare last commit to remote HEAD. +4. Print: + +``` +bread sync status + machine laptop + remote git@github.com:user/bread-sync.git + last push 2026-05-11 09:15:00 + +local changes (not yet pushed): + M bread/init.lua + A bread/modules/wifi/init.lua + +remote changes (not yet pulled): + none +``` + +**`bread sync diff`** + +Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. + +**`bread sync machines`** + +List all `machines/*.toml` files from the sync repo: + +``` + laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor + desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked +``` + +### Package manager support + +Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). + +| Manager | Snapshot command | Install command | +|---------|-----------------|-----------------| +| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | +| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | +| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | +| `cargo` | `cargo install --list` | `cargo install ` | + +For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. + +### Git operations + +Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: + +- Clone a remote repo +- Open an existing repo +- Stage all changes (`add -A` equivalent: index all tracked and untracked files) +- Create a commit with a message and the current timestamp as author date +- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) +- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) +- Fetch (without merging) +- Get diff between working tree and HEAD +- Get diff between HEAD and remote branch HEAD + +For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. + +--- + +## Part 3: Daemon additions (IPC) + +Add these IPC methods to `breadd/src/ipc/mod.rs`: + +**`sync.status`** — returns current sync state from `sync.toml` if it exists: +```json +{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } +``` +or `{ "initialized": false }` if no sync.toml. + +**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). + +No other daemon changes are needed for sync — it is entirely CLI-side. + +--- + +## Part 4: Lua API additions + +Add to `breadd/src/lua/mod.rs` in `install_api`: + +**`bread.machine`** table: + +```lua +bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml +bread.machine.tags() -- returns array of tags, or empty array +bread.machine.has_tag("mobile") -- returns bool +``` + +Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. + +**`bread.fs`** table: + +```lua +bread.fs.write(path, content) -- write string to file, create dirs as needed +bread.fs.read(path) -- read file to string, returns nil if not found +bread.fs.exists(path) -- returns bool +bread.fs.expand(path) -- expand ~ to home directory +``` + +All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. + +--- + +## Error handling requirements + +Every command must handle these cases cleanly: + +- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. +- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. +- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. +- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. +- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. +- Package manager not installed: warn and skip, do not fail the whole operation. + +--- + +## File locations + +| Purpose | Path | +|---------|------| +| Sync config | `~/.config/bread/sync.toml` | +| Local sync repo | `~/.local/share/bread/sync-repo/` | +| Module manifests | `~/.config/bread/modules//bread.module.toml` | +| Bread config | `~/.config/bread/` | +| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | + +All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. + +Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. + +--- + +## Tests + +### Module system tests (`bread-cli/tests/modules.rs`) + +```rust +// 1. Install from local path succeeds when bread.module.toml exists +// 2. Install from local path fails when bread.module.toml is missing +// 3. Remove deletes the module directory +// 4. List reads manifests correctly from disk +// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) +``` + +### Sync tests (`bread-sync/tests/sync.rs`) + +```rust +// 1. bread sync init creates sync.toml with correct fields +// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure +// 3. bread sync push snapshots bread/ directory correctly +// 4. bread sync pull copies files from repo to correct locations +// 5. Package manifest for pacman: parses output correctly +// 6. Package manifest for pip: parses output correctly +// 7. Delegates: exclude globs filter correctly +// 8. Machine profile is written to machines/.toml with correct fields +// 9. Status shows no changes when working tree matches last commit +// 10. Push with no changes prints "nothing to push" and does not create a commit +``` + +All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. + +--- + +## Completion Checklist + +Do not stop iterating until every item on this list is true. + +### Compilation +- [ ] `cargo build --workspace` succeeds with zero errors +- [ ] `cargo build --workspace --release` succeeds with zero errors +- [ ] Zero compiler warnings in new code (existing warnings are acceptable) +- [ ] `cargo clippy --workspace` produces no errors in new code + +### Tests +- [ ] `cargo test --workspace` passes with zero failures +- [ ] All tests listed in the Tests section above exist and pass +- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass + +### Module system — functional +- [ ] `bread modules install github:user/repo` downloads and installs a module +- [ ] `bread modules install /local/path` copies and installs a local module +- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 +- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` +- [ ] `bread modules install` calls `modules.reload` IPC after successful install +- [ ] `bread modules remove ` removes the module directory +- [ ] `bread modules remove ` with `--yes` skips confirmation +- [ ] `bread modules remove ` prints a clear error and exits 1 +- [ ] `bread modules list` reads all installed module manifests +- [ ] `bread modules list` shows daemon-reported status when daemon is running +- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) +- [ ] `bread modules update` re-installs all github-sourced modules +- [ ] `bread modules update` skips local-path modules with a warning +- [ ] `bread modules info ` shows all manifest fields and daemon status + +### Sync — functional +- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields +- [ ] `bread sync init` errors if already initialized +- [ ] `bread sync push` creates the correct repo directory structure +- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo +- [ ] `bread sync push` copies each delegate path to `configs//` +- [ ] `bread sync push` writes package manifests to `packages/` +- [ ] `bread sync push` writes `machines/.toml` +- [ ] `bread sync push` creates a Git commit with a sensible message +- [ ] `bread sync push` pushes to the configured remote +- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 +- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` +- [ ] `bread sync pull` copies `configs/` entries back to their original locations +- [ ] `bread sync pull` calls `modules.reload` IPC after applying +- [ ] `bread sync pull --install-packages` runs package installs +- [ ] `bread sync pull` without `--install-packages` does not run package installs +- [ ] `bread sync status` shows local uncommitted changes +- [ ] `bread sync status` shows remote changes not yet pulled +- [ ] `bread sync status` prints `nothing to push — already up to date` when clean +- [ ] `bread sync machines` lists all `machines/*.toml` entries +- [ ] `bread sync init` without `--remote` prompts for URL interactively + +### Sync — error handling +- [ ] `bread sync push` without init prints clear error and exits 1 +- [ ] `bread sync pull` without init prints clear error and exits 1 +- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 +- [ ] Package manager not installed is warned and skipped, not a fatal error + +### Lua API +- [ ] `bread.machine.name()` returns machine name from sync.toml +- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist +- [ ] `bread.machine.tags()` returns array of tags +- [ ] `bread.machine.has_tag("x")` returns true/false correctly +- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs +- [ ] `bread.fs.read(path)` returns file content as string +- [ ] `bread.fs.read(nonexistent)` returns nil, does not error +- [ ] `bread.fs.exists(path)` returns correct bool +- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path +- [ ] All `bread.fs` paths handle `~` expansion + +### Udev vendor/product ID +- [ ] `vendor_id` and `product_id` fields are present in udev device events +- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` +- [ ] `bread events` output shows `vendor_id` and `product_id` when available + +### No regressions +- [ ] `bread reload` still works +- [ ] `bread state` still works +- [ ] `bread events` still works +- [ ] `bread doctor` still works +- [ ] `bread ping` still works +- [ ] `bread emit` still works +- [ ] Daemon starts cleanly with no existing `sync.toml` +- [ ] Daemon starts cleanly with a valid `sync.toml` +- [ ] All existing IPC methods still respond correctly + +### Code quality +- [ ] No hardcoded paths containing usernames or `/home/` +- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling +- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases +- [ ] All new public functions have doc comments +- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API + +--- + +## Implementation order + +Work in this order. Do not move to the next step until the current one compiles and its tests pass. + +1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) +2. Implement `SyncConfig` (load/save `sync.toml`) +3. Implement `bread sync init` +4. Implement Git backend in `bread-sync/src/git.rs` +5. Implement `bread sync push` (bread config only, no delegates or packages yet) +6. Implement delegate file handling +7. Implement package manifest generation +8. Implement `bread sync pull` +9. Implement `bread sync status`, `diff`, `machines` +10. Implement `bread modules install` (local path first, then GitHub) +11. Implement `bread modules remove`, `list`, `update`, `info` +12. Add `vendor_id`/`product_id` to udev adapter and `Device` type +13. Add `bread.machine` Lua API +14. Add `bread.fs` Lua API +15. Write all tests +16. Run full checklist — fix anything not passing +17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 313315f..72cecc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -304,6 +325,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "flate2", + "git2", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +377,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +402,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,6 +413,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -422,6 +482,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +517,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,12 +572,53 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -606,12 +742,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -758,6 +934,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -766,11 +954,45 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1032,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -868,6 +1288,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1306,28 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,6 +1366,43 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -928,6 +1413,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -946,6 +1443,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1022,6 +1525,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1594,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1750,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1268,6 +1859,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1321,6 +1918,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,6 +1974,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1413,6 +2025,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -1442,6 +2065,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rtnetlink" version = "0.9.1" @@ -1503,6 +2166,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1512,12 +2196,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,6 +2313,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1633,6 +2361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,6 +2399,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1699,6 +2439,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1741,6 +2530,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1770,6 +2569,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2644,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2711,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,6 +2764,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1942,6 +2794,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2822,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2855,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2944,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +2997,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2237,6 +3222,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -2337,6 +3332,22 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2347,6 +3358,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -2434,6 +3468,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ab4e899..ee8711e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync" ] resolver = "2" diff --git a/README.md b/README.md index 73512df..ae1e55a 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,12 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter +-- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" -end, function(event) bread.exec("xset r rate 200 40") -end) +end, { filter = function(event) + return event.data.class == "keyboard" +end }) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 69a2c49..43c17a9 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -16,3 +17,9 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" +dirs = "5.0" +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" +chrono = { version = "0.4", features = ["serde"] } +toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0ca91df..d57890a 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,6 +1,7 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -10,6 +11,16 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; +use bread_sync::{ + config::{bread_config_dir, sync_repo_path, SyncConfig}, + delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, + git, + machine::{list_machines, machine_name, MachineProfile}, + packages::snapshot_packages, +}; + +// ─── CLI structure ──────────────────────────────────────────────────────────── + #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -47,8 +58,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + action: ModulesAction, + }, + /// Sync system state to/from a Git remote + Sync { + #[command(subcommand)] + action: SyncAction, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,6 +90,79 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ModulesAction { + /// Install a module from a source (github:user/repo[@ref] or /local/path) + Install { + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + }, + /// List installed modules with status + List, + /// Update installed modules to latest + Update { + /// Update only this specific module + name: Option, + }, + /// Show detailed manifest info for a module + Info { + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum SyncAction { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Commit message + #[arg(long, short = 'm')] + message: Option, + }, + /// Pull and apply latest state from remote + Pull { + /// Also run package install commands + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs remote + Diff { + /// Diff against remote HEAD instead of working tree + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, +} + +// ─── Module manifest ────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct ModuleManifest { + name: String, + version: String, + description: String, + author: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + installed_at: Option, +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -81,71 +173,65 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request(&socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { + let response = if let Some(path) = path { + send_request_or_die(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request_or_die(&socket, "state.dump", json!({})).await? + }; if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_json(&response)?; } else { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { - filter, - json, - fields, - since, - } => { + Commands::Events { filter, json, fields, since } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { action } => { + handle_modules(action, &socket).await?; + } + Commands::Sync { action } => { + handle_sync(action, &socket).await?; } Commands::ProfileList => { - let response = send_request(&socket, "profile.list", json!({})).await?; + let response = send_request_or_die(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?; + let response = send_request_or_die( + &socket, + "profile.activate", + json!({ "name": name }), + ) + .await?; print_json(&response)?; } Commands::Emit { event, data } => { let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); - let response = send_request( + let response = send_request_or_die( &socket, "emit", - json!({ - "event": event, - "data": parsed, - }), + json!({ "event": event, "data": parsed }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request(&socket, "ping", json!({})).await?; + let response = send_request_or_die(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -156,6 +242,699 @@ async fn main() -> Result<()> { Ok(()) } +// ─── Modules sub-commands ───────────────────────────────────────────────────── + +async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { + match action { + ModulesAction::Install { source } => { + modules_install(source, socket).await?; + } + ModulesAction::Remove { name, yes } => { + modules_remove(name, *yes, socket).await?; + } + ModulesAction::List => { + modules_list(socket).await?; + } + ModulesAction::Update { name } => { + modules_update(name.as_deref(), socket).await?; + } + ModulesAction::Info { name } => { + modules_info(name, socket).await?; + } + } + Ok(()) +} + +async fn modules_install(source: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + if let Some(rest) = source.strip_prefix("github:") { + install_github_module(rest, source, &modules_dir)?; + } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { + let local_path = expand_tilde(source); + install_local_module(&local_path, &modules_dir)?; + } else { + eprintln!("bread: unknown source format '{source}'"); + eprintln!(" expected: github:user/repo[@ref] or /local/path"); + std::process::exit(1); + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + eprintln!( + "bread: no bread.module.toml found at {}", + manifest_path.display() + ); + std::process::exit(1); + } + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(src, &dest)?; + + // Write updated manifest with installed_at + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { + let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { + (r, Some(v.to_string())) + } else { + (spec, None) + }; + + let (user, repo) = repo_spec + .split_once('/') + .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; + + let client = reqwest::blocking::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let resolved_ref = match git_ref { + Some(r) => r, + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client.get(&url).send()?.json()?; + 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/{resolved_ref}" + ); + let bytes = client.get(&tarball_url).send()?.bytes()?; + + // Extract to a temp dir + let tmp = tempfile_dir()?; + let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); + let mut archive = tar::Archive::new(gz); + archive.unpack(&tmp)?; + + // The tarball has a single top-level directory; find it + let extracted_dir = std::fs::read_dir(&tmp)? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow!("tarball contained no directory"))?; + + let manifest_path = extracted_dir.join("bread.module.toml"); + if !manifest_path.exists() { + let _ = std::fs::remove_dir_all(&tmp); + eprintln!( + "bread: no bread.module.toml found in github:{}/{} (ref {})", + user, repo, resolved_ref + ); + std::process::exit(1); + } + + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + manifest.source = source_str.to_string(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(&extracted_dir, &dest)?; + + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + let _ = std::fs::remove_dir_all(&tmp); + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + if !yes { + eprint!("remove {name}? (y/n) "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("cancelled"); + return Ok(()); + } + } + + std::fs::remove_dir_all(&module_dir)?; + println!("removed {name}"); + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_list(socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let manifests = scan_modules(&modules_dir)?; + + // Try to get daemon status + let daemon_modules = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()); + + for manifest in &manifests { + let status = daemon_modules + .as_ref() + .and_then(|mods| { + mods.iter().find(|m| { + m.get("name").and_then(Value::as_str) == Some(&manifest.name) + }) + }) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); + + println!( + " {:<20} {:<10} {:<12} {}", + manifest.name, manifest.version, status, manifest.source + ); + } + Ok(()) +} + +async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + let to_update: Vec = if let Some(name) = name { + let manifest = load_manifest(&modules_dir.join(name))?; + vec![manifest] + } else { + scan_modules(&modules_dir)? + }; + + for manifest in to_update { + if !manifest.source.starts_with("github:") { + eprintln!( + "warn: cannot update '{}' — local module, reinstall manually", + manifest.name + ); + continue; + } + let old_version = manifest.version.clone(); + let source = manifest.source.clone(); + let rest = source.trim_start_matches("github:"); + install_github_module(rest, &source, &modules_dir)?; + let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; + if new_manifest.version == old_version { + println!("{} already up to date", manifest.name); + } else { + println!( + "updated {} v{} → v{}", + manifest.name, old_version, new_manifest.version + ); + } + } + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_info(name: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + let manifest = load_manifest(&module_dir)?; + let status = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()) + .and_then(|mods| { + mods.iter() + .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()); + + println!("name: {}", manifest.name); + println!("version: {}", manifest.version); + println!("description: {}", manifest.description); + println!("author: {}", manifest.author); + println!("source: {}", manifest.source); + println!( + "installed_at: {}", + manifest.installed_at.as_deref().unwrap_or("unknown") + ); + println!("status: {status}"); + Ok(()) +} + +// ─── Sync sub-commands ──────────────────────────────────────────────────────── + +async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { + match action { + SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, + SyncAction::Push { message } => sync_push(message.as_deref()).await?, + SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, + SyncAction::Status => sync_status().await?, + SyncAction::Diff { remote } => sync_diff(*remote).await?, + SyncAction::Machines => sync_machines().await?, + } + Ok(()) +} + +async fn sync_init(remote_arg: Option<&str>) -> Result<()> { + if SyncConfig::is_initialized()? { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + bread_sync::config::config_path()?.display() + ); + std::process::exit(1); + } + + let remote_url = if let Some(url) = remote_arg { + url.to_string() + } else { + eprint!("Sync remote URL (git remote or path): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let url = input.trim().to_string(); + if url.is_empty() { + anyhow::bail!("remote URL is required"); + } + url + }; + + let default_hostname = hostname_or_unknown(); + eprint!("Machine name [{}]: ", default_hostname); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let machine_name = { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + default_hostname.clone() + } else { + trimmed + } + }; + + eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let tags: Vec = input + .trim() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let cfg = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: Some(remote_url.clone()), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: Some(machine_name.clone()), + tags, + }, + ..Default::default() + }; + cfg.save()?; + + // Validate remote if it looks like a URL + if !remote_url.starts_with('/') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!("sync initialized:"); + println!(" machine: {machine_name}"); + println!(" remote: {remote_url}"); + Ok(()) +} + +async fn sync_push(message: Option<&str>) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + // Snapshot bread config + let bread_dir = bread_config_dir()?; + let bread_dest = repo_path.join("bread"); + sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; + + // Snapshot delegates + copy_delegates_to_repo(&cfg.delegates, &repo_path)?; + + // Snapshot packages + if cfg.packages.enabled { + snapshot_packages(&cfg.packages.managers, &repo_path)?; + } + + // Write machine profile + let profile = MachineProfile::new(&cfg)?; + profile.write_to_repo(&repo_path)?; + + // Stage all + git::stage_all(&repo)?; + + // Check for changes + if !git::has_changes(&repo)? { + println!("nothing to push — already up to date"); + return Ok(()); + } + + // Commit + let machine = machine_name(&cfg)?; + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let commit_msg = message + .map(ToString::to_string) + .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); + git::commit(&repo, &commit_msg)?; + + // Set remote and push + if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} + git::push(&repo, "origin", &branch)?; + + println!("pushed: {commit_msg}"); + println!(" bread config: {}", bread_dir.display()); + if cfg.packages.enabled { + println!(" packages: {}", cfg.packages.managers.join(", ")); + } + Ok(()) +} + +async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + git::pull(&repo, "origin", &branch)?; + + // Restore bread config + let bread_src = repo_path.join("bread"); + let bread_dest = bread_config_dir()?; + if bread_src.exists() { + sync_dir(&bread_src, &bread_dest, &[])?; + } + + // Restore delegates + restore_delegates_from_repo(&cfg.delegates, &repo_path)?; + + // Package installs + if install_packages && cfg.packages.enabled { + run_package_installs(&repo_path, &cfg.packages.managers)?; + } else if cfg.packages.enabled { + let pkg_dir = repo_path.join("packages"); + if pkg_dir.exists() { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + + println!("pulled and applied latest state"); + Ok(()) +} + +async fn sync_status() -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not yet initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + let machine = machine_name(&cfg)?; + let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); + let last_push = git::last_commit_time(&repo); + + println!("bread sync status"); + println!(" machine {machine}"); + println!(" remote {remote_url}"); + println!(" last push {last_push}"); + + let local_changes = git::status_lines(&repo)?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {ch} {path}"); + } + } + + // Fetch to check remote + let _ = git::fetch(&repo, "origin"); + let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); + println!(); + println!("remote changes (not yet pulled):"); + if has_remote { + println!(" (run 'bread sync pull' to apply)"); + } else { + println!(" none"); + } + Ok(()) +} + +async fn sync_diff(show_remote: bool) -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + + let diff = if show_remote { + git::fetch(&repo, "origin")?; + git::diff_remote(&repo, "origin", &cfg.remote.branch)? + } else { + git::diff_workdir(&repo)? + }; + + if diff.is_empty() { + println!("no differences"); + } else { + print!("{diff}"); + } + Ok(()) +} + +async fn sync_machines() -> Result<()> { + let repo_path = sync_repo_path()?; + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + let machines = list_machines(&repo_path); + if machines.is_empty() { + println!("no machines found in sync repo"); + return Ok(()); + } + for m in machines { + let tags = if m.tags.is_empty() { + "(none)".to_string() + } else { + m.tags.join(", ") + }; + println!( + " {:<20} last sync: {:<20} tags: {}", + m.name, m.last_sync, tags + ); + } + Ok(()) +} + +fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + let f = pkg_dir.join("pacman.txt"); + if f.exists() { + let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); + let status = std::process::Command::new("sudo") + .args(["pacman", "-S", "--needed"]) + .args(&names) + .status(); + if let Err(e) = status { + eprintln!("warn: pacman install failed: {e}"); + } + } + } + "pip" => { + let f = pkg_dir.join("pip.txt"); + if f.exists() { + let status = std::process::Command::new("pip") + .args(["install", "--user", "-r"]) + .arg(&f) + .status(); + if let Err(e) = status { + eprintln!("warn: pip install failed: {e}"); + } + } + } + "npm" => { + let f = pkg_dir.join("npm.txt"); + if f.exists() { + let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); + for name in names { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &name]) + .status(); + } + } + } + "cargo" => { + let f = pkg_dir.join("cargo.txt"); + if f.exists() { + let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); + for entry in entries { + let name = entry.split_whitespace().next().unwrap_or(&entry); + let _ = std::process::Command::new("cargo") + .args(["install", name]) + .status(); + } + } + } + _ => {} + } + } + Ok(()) +} + +// ─── Helper functions ───────────────────────────────────────────────────────── + +fn require_sync_config() -> Result { + if !SyncConfig::is_initialized()? { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + SyncConfig::load() +} + +fn modules_directory() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow!("cannot determine config directory"))?; + let dir = config_dir.join("bread").join("modules"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn scan_modules(modules_dir: &Path) -> Result> { + let mut out = Vec::new(); + if !modules_dir.exists() { + return Ok(out); + } + for entry in std::fs::read_dir(modules_dir)? { + let entry = entry?; + if !entry.path().is_dir() { + continue; + } + if let Ok(manifest) = load_manifest(&entry.path()) { + out.push(manifest); + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +fn load_manifest(module_dir: &Path) -> Result { + let path = module_dir.join("bread.module.toml"); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) +} + +fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dest_path = dest.join(entry.file_name()); + if entry.path().is_dir() { + copy_dir_all(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), dest_path)?; + } + } + Ok(()) +} + +fn tempfile_dir() -> Result { + let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); + std::fs::create_dir_all(&tmp)?; + Ok(tmp) +} + +fn hostname_or_unknown() -> String { + std::fs::read_to_string("/etc/hostname") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ─── IPC helpers ────────────────────────────────────────────────────────────── + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -163,6 +942,26 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } +/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. +async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { + match send_request(socket, method, params).await { + Ok(v) => Ok(v), + Err(err) => { + let msg = err.to_string(); + if msg.contains("No such file") + || msg.contains("Connection refused") + || msg.contains("not found") + { + eprintln!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ); + std::process::exit(1); + } + Err(err) + } + } +} + async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -195,7 +994,8 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + let replay = + send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; if let Some(list) = replay.as_array() { for item in list { if raw_json { @@ -212,9 +1012,7 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { - "filter": filter, - }, + "params": { "filter": filter }, }); write_half @@ -230,10 +1028,11 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } - Ok(()) } +// ─── Display helpers ────────────────────────────────────────────────────────── + fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -297,15 +1096,11 @@ fn format_timestamp(ms: u64) -> String { let secs = ms / 1000; let millis = ms % 1000; - // SAFETY: localtime_r is thread-safe. We pass a valid pointer to a - // zeroed tm struct and read the result only after the call returns. let local_secs = unsafe { let mut tm: libc::tm = std::mem::zeroed(); let t = secs as libc::time_t; libc::localtime_r(&t, &mut tm); - tm.tm_hour as u64 * 3600 - + tm.tm_min as u64 * 60 - + tm.tm_sec as u64 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -345,16 +1140,11 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } - - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - - let response = send_request(socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(socket, "modules.reload", json!({})).await?; print_reload(&response); } - Ok(()) } @@ -387,7 +1177,11 @@ fn render_doctor(health: &Value) { let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown"); let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); - println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); + println!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -458,11 +1252,7 @@ async fn send_request_with_stream( } fn config_directory() -> PathBuf { - if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { - return Path::new(&xdg).join("bread"); - } - if let Ok(home) = env::var("HOME") { - return Path::new(&home).join(".config/bread"); - } - PathBuf::from(".config/bread") + dirs::config_dir() + .map(|d| d.join("bread")) + .unwrap_or_else(|| PathBuf::from(".config/bread")) } diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..c4860dc --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bread-sync" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5.0" +git2 = { version = "0.18", features = ["vendored-libgit2"] } +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" + +[dev-dependencies] +tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..079b8d6 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,10 @@ +# bread-sync + +Sync and module management library for the Bread reactive desktop automation daemon. + +Provides: +- `SyncConfig` — load/save `~/.config/bread/sync.toml` +- Git backend (via git2) for push/pull of bread config to a remote repository +- Delegate file handling — copy arbitrary config files into the sync repo +- Package manifest generation for pacman/pip/npm/cargo +- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..d0b7506 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,124 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncConfig { + #[serde(default)] + pub remote: RemoteConfig, + #[serde(default)] + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +/// Git remote configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RemoteConfig { + pub url: Option, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +/// Machine identity — name comes from here, falls back to hostname. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MachineConfig { + pub name: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Which package managers to snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_managers")] + pub managers: Vec, +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: default_managers(), + } + } +} + +fn default_true() -> bool { + true +} + +fn default_managers() -> Vec { + vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] +} + +/// Config file delegation — which extra paths to include in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + /// Absolute or `~`-prefixed paths to copy into `configs//`. + #[serde(default)] + pub include: Vec, + /// Glob patterns to exclude when copying. + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. + pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(&path)?; + let cfg: Self = toml::from_str(&raw)?; + Ok(cfg) + } + + /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. + pub fn save(&self) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. + pub fn is_initialized() -> Result { + Ok(config_path()?.exists()) + } +} + +/// Path to `~/.config/bread/sync.toml`. +pub fn config_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread").join("sync.toml")) +} + +/// Path to `~/.local/share/bread/sync-repo/`. +pub fn sync_repo_path() -> Result { + let data_dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; + Ok(data_dir.join("bread").join("sync-repo")) +} + +/// Path to `~/.config/bread/`. +pub fn bread_config_dir() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread")) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..aadab3b --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,205 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::config::DelegatesConfig; + +/// Expand `~` in a path string to the user's home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + dirs::home_dir() + .map(|h| h.join(rest)) + .unwrap_or_else(|| PathBuf::from(path)) + } else if path == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) + } else { + PathBuf::from(path) + } +} + +/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. +fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { + let rel = path.strip_prefix(base).unwrap_or(path); + let rel_str = rel.to_string_lossy(); + for pattern in excludes { + if glob_matches(pattern, &rel_str) { + return true; + } + } + false +} + +/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. +/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are +/// absent in `src` (rsync `--delete` behaviour). +pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + std::fs::create_dir_all(dest)?; + copy_recursive(src, src, dest, excludes)?; + delete_extra(src, dest)?; + Ok(()) +} + +fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + + if is_excluded(root, &src_path, excludes) { + continue; + } + + let file_name = entry.file_name(); + let dest_path = dest.join(&file_name); + + if src_path.is_dir() { + std::fs::create_dir_all(&dest_path)?; + copy_recursive(root, &src_path, &dest_path, excludes)?; + } else { + std::fs::copy(&src_path, &dest_path)?; + } + } + Ok(()) +} + +/// Remove files/dirs from `dest` that don't exist in `src`. +fn delete_extra(src: &Path, dest: &Path) -> Result<()> { + if !dest.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dest)? { + let entry = entry?; + let dest_path = entry.path(); + let file_name = entry.file_name(); + let src_path = src.join(&file_name); + if !src_path.exists() { + if dest_path.is_dir() { + std::fs::remove_dir_all(&dest_path)?; + } else { + std::fs::remove_file(&dest_path)?; + } + } + } + Ok(()) +} + +/// Copy each `include` path into `/configs//`. +pub fn copy_delegates_to_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + std::fs::create_dir_all(&configs_dir)?; + + for raw_path in &cfg.include { + let src = expand_tilde(raw_path); + if !src.exists() { + tracing_warn(&format!( + "delegate path does not exist, skipping: {}", + src.display() + )); + continue; + } + let basename = src + .file_name() + .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; + let dest = configs_dir.join(basename); + if src.is_dir() { + sync_dir(&src, &dest, &cfg.exclude)?; + } else { + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Restore each delegate path from `/configs//` to its original location. +pub fn restore_delegates_from_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + + for raw_path in &cfg.include { + let dest = expand_tilde(raw_path); + let basename = match dest.file_name() { + Some(n) => n.to_os_string(), + None => continue, + }; + let src = configs_dir.join(&basename); + if !src.exists() { + continue; + } + if src.is_dir() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + sync_dir(&src, &dest, &[])?; + } else { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Simple glob match for `**` and `*` patterns against a path string. +fn glob_matches(pattern: &str, path: &str) -> bool { + glob_match_bytes(pattern.as_bytes(), path.as_bytes()) +} + +fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + // `**` matches any sequence including path separators + if pattern.starts_with(b"**") { + let rest = &pattern[2..]; + if rest.is_empty() { + return true; + } + // skip leading separator in rest + let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; + for offset in 0..=text.len() { + if glob_match_bytes(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if glob_match_bytes(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + } +} + +fn tracing_warn(msg: &str) { + // Use eprintln since tracing may not be configured in library context + eprintln!("warn: {msg}"); +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..581efbc --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,227 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; + +/// Open an existing repo or initialise a new one at `path`. +pub fn init_or_open(path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + Ok(git2::Repository::open(path)?) + } else { + std::fs::create_dir_all(path)?; + Ok(git2::Repository::init(path)?) + } +} + +/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. +pub fn clone_or_open(url: &str, path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + return Ok(git2::Repository::open(path)?); + } + let mut builder = git2::build::RepoBuilder::new(); + let mut fetch_opts = git2::FetchOptions::new(); + fetch_opts.remote_callbacks(make_callbacks()); + builder.fetch_options(fetch_opts); + std::fs::create_dir_all(path)?; + Ok(builder.clone(url, path)?) +} + +/// Stage every tracked and untracked change (equivalent to `git add -A`). +pub fn stage_all(repo: &git2::Repository) -> Result<()> { + let mut index = repo.index()?; + index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; + // Remove entries for deleted files + index.update_all(["*"].iter(), None)?; + index.write()?; + Ok(()) +} + +/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). +pub fn has_changes(repo: &git2::Repository) -> Result { + let mut index = repo.index()?; + index.read(false)?; + + // New repo with no commits yet + if repo.head().is_err() { + return Ok(index.len() > 0); + } + + let head = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; + Ok(diff.deltas().count() > 0) +} + +/// Commit all staged changes with `message`. Returns the new commit OID. +pub fn commit(repo: &git2::Repository, message: &str) -> Result { + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = repo.signature().unwrap_or_else(|_| { + git2::Signature::now("bread", "bread@localhost").expect("signature") + }); + + let oid = if let Ok(head) = repo.head() { + let parent = head.peel_to_commit()?; + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } else { + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + }; + Ok(oid) +} + +/// Push `branch` to `remote_name` (defaults to "origin"). +pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::PushOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.push( + &[&format!("refs/heads/{branch}:refs/heads/{branch}")], + Some(&mut opts), + )?; + Ok(()) +} + +/// Fetch from `remote_name` without merging. +pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.fetch(&[] as &[&str], Some(&mut opts), None)?; + Ok(()) +} + +/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. +pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + fetch(repo, remote_name)?; + + let fetch_head = repo + .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) + .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; + let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; + + let analysis = repo.merge_analysis(&[&fetch_commit])?; + if analysis.0.is_up_to_date() { + return Ok(()); + } + if !analysis.0.is_fast_forward() { + return Err(anyhow!( + "sync conflict — resolve manually in {}", + repo.workdir() + .unwrap_or_else(|| Path::new("?")) + .display() + )); + } + + // Fast-forward: update HEAD and checkout + let head_ref = repo.find_reference("HEAD")?; + let resolved = head_ref.resolve()?; + let refname = resolved.name().unwrap_or("HEAD").to_string(); + repo.find_reference(&refname)? + .set_target(fetch_commit.id(), "fast-forward")?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; + Ok(()) +} + +/// Add a remote named `name` pointing at `url`, or update it if it already exists. +pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { + if repo.find_remote(name).is_ok() { + repo.remote_set_url(name, url)?; + } else { + repo.remote(name, url)?; + } + Ok(()) +} + +/// Return working-tree diff against HEAD as a unified diff string. +pub fn diff_workdir(repo: &git2::Repository) -> Result { + let mut buf = Vec::new(); + if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { + let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return diff between HEAD and `remote/branch` as a unified diff string. +pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_tree = repo + .find_reference(&remote_ref) + .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? + .peel_to_tree()?; + let local_tree = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; + let mut buf = Vec::new(); + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return a list of `(status_char, path)` for the working tree. +pub fn status_lines(repo: &git2::Repository) -> Result> { + let statuses = repo.statuses(None)?; + let mut out = Vec::new(); + for entry in statuses.iter() { + let path = entry.path().unwrap_or("?").to_string(); + let flag = entry.status(); + let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { + 'A' + } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { + 'D' + } else { + 'M' + }; + out.push((ch, path)); + } + Ok(out) +} + +/// Returns true if the local HEAD is behind the remote. +pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let Ok(remote_ref) = repo.find_reference(&remote_ref) else { + return false; + }; + let Ok(remote_commit) = remote_ref.peel_to_commit() else { + return false; + }; + let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return false; + }; + remote_commit.id() != local_commit.id() +} + +/// Timestamp of the HEAD commit (or "never"). +pub fn last_commit_time(repo: &git2::Repository) -> String { + let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return "never".to_string(); + }; + let ts = commit.time().seconds(); + let dt = chrono::DateTime::::from_timestamp(ts, 0) + .unwrap_or_else(chrono::Utc::now); + dt.format("%Y-%m-%d %H:%M:%S").to_string() +} + +fn is_bare(path: &Path) -> bool { + path.join("HEAD").exists() && path.join("objects").exists() +} + +fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + } else if allowed_types.contains(git2::CredentialType::DEFAULT) { + git2::Cred::default() + } else { + Err(git2::Error::from_str( + "no supported credential type (SSH agent or default)", + )) + } + }); + callbacks +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..454a78a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::{ + bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, + RemoteConfig, SyncConfig, +}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..e4e4bb1 --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::SyncConfig; + +/// Machine profile persisted to `/machines/.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineProfile { + pub name: String, + pub hostname: String, + pub tags: Vec, + pub last_sync: String, // RFC 3339 +} + +impl MachineProfile { + pub fn new(cfg: &SyncConfig) -> Result { + let host = hostname()?; + let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); + Ok(Self { + name, + hostname: host, + tags: cfg.machine.tags.clone(), + last_sync: Utc::now().to_rfc3339(), + }) + } + + /// Write profile to `/machines/.toml`. + pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { + let machines_dir = repo_root.join("machines"); + std::fs::create_dir_all(&machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Load from `/machines/.toml`. + pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { + let path = repo_root.join("machines").join(format!("{name}.toml")); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) + } +} + +/// List all machine profiles in `/machines/`. +pub fn list_machines(repo_root: &Path) -> Vec { + let machines_dir = repo_root.join("machines"); + let Ok(entries) = std::fs::read_dir(&machines_dir) else { + return Vec::new(); + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) + .filter_map(|e| { + std::fs::read_to_string(e.path()) + .ok() + .and_then(|raw| toml::from_str::(&raw).ok()) + }) + .collect() +} + +/// Returns the machine name from sync.toml, falling back to hostname. +pub fn machine_name(cfg: &SyncConfig) -> Result { + if let Some(name) = cfg.machine.name.as_deref() { + return Ok(name.to_string()); + } + hostname() +} + +/// Returns the machine tags from sync.toml. +pub fn machine_tags(cfg: &SyncConfig) -> Vec { + cfg.machine.tags.clone() +} + +/// Returns true if `tag` is in the machine's tag list. +pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { + cfg.machine.tags.iter().any(|t| t == tag) +} + +fn hostname() -> Result { + // Try /etc/hostname first (no subprocess) + if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { + let trimmed = raw.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + // Fall back to hostname(1) + let out = std::process::Command::new("hostname") + .output() + .map_err(anyhow::Error::from)?; + let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; + Ok(s.trim().to_string()) +} + +#[allow(dead_code)] +fn format_last_sync(dt: &DateTime) -> String { + dt.format("%Y-%m-%d %H:%M").to_string() +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..333e0aa --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,137 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::Result; + +/// Write package manifests to `/packages/`. +/// Skips package managers that are not installed (warns instead of erroring). +pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + std::fs::create_dir_all(&pkg_dir)?; + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + if let Some(content) = run_pacman() { + std::fs::write(pkg_dir.join("pacman.txt"), content)?; + } else { + eprintln!("warn: pacman not found, skipping package snapshot"); + } + } + "pip" => { + if let Some(content) = run_pip() { + std::fs::write(pkg_dir.join("pip.txt"), content)?; + } else { + eprintln!("warn: pip not found, skipping package snapshot"); + } + } + "npm" => { + if let Some(content) = run_npm() { + std::fs::write(pkg_dir.join("npm.txt"), content)?; + } else { + eprintln!("warn: npm not found, skipping package snapshot"); + } + } + "cargo" => { + if let Some(content) = run_cargo() { + std::fs::write(pkg_dir.join("cargo.txt"), content)?; + } else { + eprintln!("warn: cargo not found, skipping package snapshot"); + } + } + other => { + eprintln!("warn: unknown package manager '{other}', skipping"); + } + } + } + Ok(()) +} + +/// Parse a `pacman.txt` snapshot into a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() +} + +/// Parse a `pip.txt` (freeze format) snapshot into package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) + .collect() +} + +/// Parse an `npm.txt` (parseable) snapshot into package names. +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .skip(1) // first line is the npm global prefix path + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + Path::new(l.trim()) + .file_name() + .and_then(|n| n.to_str()) + .map(ToString::to_string) + }) + .collect() +} + +/// Parse `cargo install --list` output into `name version` lines. +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .filter_map(|l| { + // Format: `name v1.2.3 (...):` or `name v1.2.3:` + let parts: Vec<&str> = l.splitn(2, ' ').collect(); + if parts.len() == 2 { + let name = parts[0]; + let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); + Some(format!("{name} {version}")) + } else { + None + } + }) + .collect() +} + +fn run_pacman() -> Option { + let output = Command::new("pacman").args(["-Qe"]).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_pip() -> Option { + let output = Command::new("pip") + .args(["list", "--user", "--format=freeze"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_npm() -> Option { + let output = Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_cargo() -> Option { + let output = Command::new("cargo") + .args(["install", "--list"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..ce76abf --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1 @@ +// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..36189a0 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,20 +22,25 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { - scan_devices(&self.subsystems).unwrap_or_default() - }); + let payloads = match enumerate_with_udev(&self.subsystems) { + Ok(p) => p, + Err(_) => scan_devices(&self.subsystems) + .unwrap_or_default() + .into_iter() + .map(|d| json!({ + "action": "add", + "id": d.id, + "name": d.name, + "subsystem": d.subsystem, + })) + .collect(), + }; - for device in devices { + for payload in payloads { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload: json!({ - "action": "add", - "id": device.id, - "name": device.name, - "subsystem": device.subsystem, - }), + payload, timestamp: now_unix_ms(), }) .await?; @@ -164,7 +169,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -184,16 +189,38 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(ScannedDevice { - id, - name, - subsystem, - }); + out.push(json!({ + "action": "add", + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), + "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), + "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), + "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), + "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), + "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), + "id_model": dev_prop_str(&dev, "ID_MODEL"), + })); } Ok(out) } +fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { + dev.property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { + dev.property_value(key) + .map(|v| v.to_string_lossy().to_string()) +} + fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From 96e42bc3704ec021e151b869bc41d2fe05a255b1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 22:51:32 +0800 Subject: [PATCH 2/4] revert --- .github/workflows/ci.yml | 42 ++ .gitignore | 6 +- CLAUDE_SPEC.md | 604 ------------------- Cargo.lock | 1090 +---------------------------------- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 - bread-cli/src/main.rs | 904 ++--------------------------- bread-sync/Cargo.toml | 19 - bread-sync/README.md | 10 - bread-sync/src/config.rs | 124 ---- bread-sync/src/delegates.rs | 205 ------- bread-sync/src/git.rs | 227 -------- bread-sync/src/lib.rs | 10 - bread-sync/src/machine.rs | 102 ---- bread-sync/src/packages.rs | 137 ----- bread-sync/tests/sync.rs | 1 - breadd/src/adapters/udev.rs | 59 +- 18 files changed, 125 insertions(+), 3432 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 CLAUDE_SPEC.md delete mode 100644 bread-sync/Cargo.toml delete mode 100644 bread-sync/README.md delete mode 100644 bread-sync/src/config.rs delete mode 100644 bread-sync/src/delegates.rs delete mode 100644 bread-sync/src/git.rs delete mode 100644 bread-sync/src/lib.rs delete mode 100644 bread-sync/src/machine.rs delete mode 100644 bread-sync/src/packages.rs delete mode 100644 bread-sync/tests/sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7409b04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + - name: Cargo cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + - name: Build + run: cargo build --workspace --verbose + - name: Run tests + run: cargo test --workspace --verbose + - name: Build release + run: cargo build --workspace --release + - name: Package artifacts + run: | + mkdir -p dist + tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bread-${{ matrix.os }} + path: dist/*.tgz diff --git a/.gitignore b/.gitignore index f8f98d0..0c56659 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github \ No newline at end of file +<<<<<<< HEAD +.github +======= +.github/ +>>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md deleted file mode 100644 index 2a2d1df..0000000 --- a/CLAUDE_SPEC.md +++ /dev/null @@ -1,604 +0,0 @@ -# Bread — Sync & Module System Implementation Spec -### Instructions for Claude Code - -This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. - ---- - -## Context - -Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: - -- `breadd/` — the runtime daemon (Rust + Lua via mlua) -- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) -- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) - -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. - -The two things being added in this iteration: - -1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs -2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote - ---- - -## Part 1: Module System - -### What a module is - -A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. - -A module directory looks like: - -``` -~/.config/bread/modules/ -└── wifi/ - ├── bread.module.toml ← module manifest (required) - ├── init.lua ← entry point (required) - └── lib/ ← optional support files -``` - -### Module manifest (`bread.module.toml`) - -Every installed module must have a manifest: - -```toml -name = "wifi" -version = "1.0.0" -description = "WiFi management for Bread" -author = "someuser" -source = "github:someuser/bread-wifi" # where it was installed from -installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install -``` - -All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. - -### Install sources - -The module installer must support these source formats: - -``` -github:user/repo # installs default branch -github:user/repo@v1.2.0 # installs specific tag -github:user/repo@abc1234 # installs specific commit -/path/to/local/dir # installs from local directory (copies it) -``` - -Anything else is an error with a clear message. - -### New Cargo dependencies allowed - -Add to `bread-cli/Cargo.toml` as needed: -- `git2 = "0.18"` for Git operations -- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API -- `flate2`, `tar` for archive extraction - -Add to `breadd/Cargo.toml` as needed: -- `git2 = "0.18"` -- `toml = "0.8"` (already present) - -### CLI commands to implement - -All module commands live under `bread modules`: - -``` -bread modules install Install a module -bread modules remove Remove an installed module -bread modules list List installed modules with name, version, status -bread modules update Update all installed modules to latest -bread modules update Update a specific module -bread modules info Show full manifest details for a module -``` - -**`bread modules install `** - -1. Parse the source string. -2. For `github:user/repo[@ref]`: - - Use the GitHub API to resolve the ref (or default branch if none specified). - - Download the repository archive as a `.tar.gz`. - - Extract to a temp directory. - - Verify a `bread.module.toml` exists at the root. If not, error cleanly. - - Copy the module directory to `~/.config/bread/modules//`. - - Write `installed_at` into the manifest. -3. For local paths: - - Verify the path exists and contains `bread.module.toml`. - - Copy to `~/.config/bread/modules//`. - - Write `installed_at`. -4. Print `installed v` on success. -5. Tell the daemon to reload via IPC (`modules.reload`) after install. - -**`bread modules remove `** - -1. Find `~/.config/bread/modules//`. -2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. -3. Delete the directory. -4. Tell the daemon to reload via IPC. -5. Print `removed `. - -**`bread modules list`** - -Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: - -``` - wifi 1.0.0 loaded github:someuser/bread-wifi - redox 0.3.1 loaded github:breadway/bread-redox - broken-mod 0.1.0 error /home/user/local-module -``` - -Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. - -**`bread modules update [name]`** - -1. Read `bread.module.toml` for each module to update. -2. If `source` starts with `github:`, re-run the install for that source. -3. If `source` is a local path, error with `cannot update local module — reinstall manually`. -4. Print `updated v → v` or ` already up to date`. - -**`bread modules info `** - -Print full manifest contents plus daemon-reported status. Example: - -``` -name: wifi -version: 1.0.0 -description: WiFi management for Bread -author: someuser -source: github:someuser/bread-wifi -installed_at: 2026-05-11T09:00:00Z -status: loaded -``` - -### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events - -In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: - -```rust -"vendor_id": prop_str(&event, "ID_VENDOR_ID"), -"product_id": prop_str(&event, "ID_MODEL_ID"), -``` - -These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. - ---- - -## Part 2: Sync System - -### Overview - -Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: - -1. **Bread config** — everything in `~/.config/bread/` (always included) -2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) -3. **Package manifest** — lists of explicitly-installed packages per package manager -4. **Machine profile** — machine name and tags for machine-aware config - -Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. - -### New crate: `bread-sync` - -Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. - -``` -bread-sync/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── config.rs ← SyncConfig type, load/save - ├── git.rs ← Git operations via git2 - ├── packages.rs ← Package manifest generation - ├── delegates.rs ← Config file delegation - └── machine.rs ← Machine profile -``` - -`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. - -### Sync configuration (`~/.config/bread/sync.toml`) - -This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. - -```toml -[remote] -url = "git@github.com:user/bread-sync.git" # required, set by bread sync init -branch = "main" # default: "main" - -[machine] -name = "laptop" # required, set by bread sync init -tags = ["mobile", "battery", "single-monitor"] # user-defined, optional - -[packages] -enabled = true -managers = ["pacman", "pip", "npm"] # which package managers to snapshot - -[delegates] -# Additional config directories to include in sync. -# ~/.config/bread/ is always included and does not need to be listed here. -include = [ - "~/.config/nvim", - "~/.config/fish", - "~/.config/kitty", -] -exclude = [ - "**/.git", - "**/node_modules", - "**/__pycache__", - "**/*.log", - "**/*.cache", - "~/.config/nvim/.repro", -] -``` - -All paths support `~` expansion. Globs in `exclude` use standard glob syntax. - -### Sync repo layout - -The Git repository managed by Bread has this structure: - -``` -/ -├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) -├── configs/ -│ ├── nvim/ ← copy of ~/.config/nvim/ -│ ├── fish/ ← copy of ~/.config/fish/ -│ └── kitty/ ← copy of ~/.config/kitty/ -├── packages/ -│ ├── pacman.txt ← output of `pacman -Qe` -│ ├── pip.txt ← output of `pip list --user --format=freeze` -│ └── npm.txt ← output of `npm list -g --depth=0` -├── machines/ -│ └── laptop.toml ← machine profile for this machine -└── .bread-sync ← sync metadata (not committed to Git) -``` - -`machines/.toml` contains: - -```toml -name = "laptop" -hostname = "breadway-laptop" # auto-detected via gethostname -tags = ["mobile", "battery", "single-monitor"] -last_sync = "2026-05-11T09:15:00Z" -``` - -### CLI commands to implement - -All sync commands live under `bread sync`: - -``` -bread sync init [--remote ] Initialize sync for this machine -bread sync push [--message ] Snapshot and push current state -bread sync pull Pull and apply latest state -bread sync status Show what has changed since last push -bread sync diff Show file-level diff vs remote -bread sync machines List known machines from sync repo -``` - -**`bread sync init [--remote ]`** - -1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` -2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. -3. Prompt: `Machine name [laptop]: ` (default: hostname). -4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. -5. Create `~/.config/bread/sync.toml` with the provided values. -6. If the remote is a URL (not a local path), check if the repo exists: - - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). - - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. -7. Print setup summary. - -**`bread sync push [--message ]`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. -3. Snapshot each section: - - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) - - For each path in `delegates.include`: copy to `/configs//` - - If `packages.enabled`: run package manager queries and write to `/packages/` - - Write `/machines/.toml` -4. Stage all changes (`git add -A`). -5. If there are no changes, print `nothing to push — already up to date` and exit. -6. Commit with message: `sync: ` or the user-provided `--message`. -7. Push to remote. -8. Print a summary of what was snapshotted. - -**`bread sync pull`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Pull from remote (fetch + merge or rebase — use merge, simpler). -3. Apply each section in order: - - Copy `/bread/` → `~/.config/bread/` (same rsync-style) - - For each path in `delegates.include` that exists in `/configs/`: copy back - - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) -4. Tell the daemon to reload via IPC (`modules.reload`) after applying. -5. Print a summary of what was applied. - -**Package install on pull** (only when `--install-packages` is explicitly passed): - -- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` -- `pip.txt` → `pip install --user -r pip.txt` -- `npm.txt` → parse package names and run `npm install -g` - -Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. - -**`bread sync status`** - -1. Load sync config and local repo. -2. Pull remote refs without merging (fetch only). -3. Compare working tree to last commit and compare last commit to remote HEAD. -4. Print: - -``` -bread sync status - machine laptop - remote git@github.com:user/bread-sync.git - last push 2026-05-11 09:15:00 - -local changes (not yet pushed): - M bread/init.lua - A bread/modules/wifi/init.lua - -remote changes (not yet pulled): - none -``` - -**`bread sync diff`** - -Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. - -**`bread sync machines`** - -List all `machines/*.toml` files from the sync repo: - -``` - laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor - desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked -``` - -### Package manager support - -Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). - -| Manager | Snapshot command | Install command | -|---------|-----------------|-----------------| -| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | -| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | -| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | -| `cargo` | `cargo install --list` | `cargo install ` | - -For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. - -### Git operations - -Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: - -- Clone a remote repo -- Open an existing repo -- Stage all changes (`add -A` equivalent: index all tracked and untracked files) -- Create a commit with a message and the current timestamp as author date -- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) -- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) -- Fetch (without merging) -- Get diff between working tree and HEAD -- Get diff between HEAD and remote branch HEAD - -For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. - ---- - -## Part 3: Daemon additions (IPC) - -Add these IPC methods to `breadd/src/ipc/mod.rs`: - -**`sync.status`** — returns current sync state from `sync.toml` if it exists: -```json -{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } -``` -or `{ "initialized": false }` if no sync.toml. - -**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). - -No other daemon changes are needed for sync — it is entirely CLI-side. - ---- - -## Part 4: Lua API additions - -Add to `breadd/src/lua/mod.rs` in `install_api`: - -**`bread.machine`** table: - -```lua -bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml -bread.machine.tags() -- returns array of tags, or empty array -bread.machine.has_tag("mobile") -- returns bool -``` - -Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. - -**`bread.fs`** table: - -```lua -bread.fs.write(path, content) -- write string to file, create dirs as needed -bread.fs.read(path) -- read file to string, returns nil if not found -bread.fs.exists(path) -- returns bool -bread.fs.expand(path) -- expand ~ to home directory -``` - -All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. - ---- - -## Error handling requirements - -Every command must handle these cases cleanly: - -- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. -- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. -- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. -- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. -- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. -- Package manager not installed: warn and skip, do not fail the whole operation. - ---- - -## File locations - -| Purpose | Path | -|---------|------| -| Sync config | `~/.config/bread/sync.toml` | -| Local sync repo | `~/.local/share/bread/sync-repo/` | -| Module manifests | `~/.config/bread/modules//bread.module.toml` | -| Bread config | `~/.config/bread/` | -| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | - -All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. - -Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. - ---- - -## Tests - -### Module system tests (`bread-cli/tests/modules.rs`) - -```rust -// 1. Install from local path succeeds when bread.module.toml exists -// 2. Install from local path fails when bread.module.toml is missing -// 3. Remove deletes the module directory -// 4. List reads manifests correctly from disk -// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) -``` - -### Sync tests (`bread-sync/tests/sync.rs`) - -```rust -// 1. bread sync init creates sync.toml with correct fields -// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure -// 3. bread sync push snapshots bread/ directory correctly -// 4. bread sync pull copies files from repo to correct locations -// 5. Package manifest for pacman: parses output correctly -// 6. Package manifest for pip: parses output correctly -// 7. Delegates: exclude globs filter correctly -// 8. Machine profile is written to machines/.toml with correct fields -// 9. Status shows no changes when working tree matches last commit -// 10. Push with no changes prints "nothing to push" and does not create a commit -``` - -All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. - ---- - -## Completion Checklist - -Do not stop iterating until every item on this list is true. - -### Compilation -- [ ] `cargo build --workspace` succeeds with zero errors -- [ ] `cargo build --workspace --release` succeeds with zero errors -- [ ] Zero compiler warnings in new code (existing warnings are acceptable) -- [ ] `cargo clippy --workspace` produces no errors in new code - -### Tests -- [ ] `cargo test --workspace` passes with zero failures -- [ ] All tests listed in the Tests section above exist and pass -- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass - -### Module system — functional -- [ ] `bread modules install github:user/repo` downloads and installs a module -- [ ] `bread modules install /local/path` copies and installs a local module -- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 -- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` -- [ ] `bread modules install` calls `modules.reload` IPC after successful install -- [ ] `bread modules remove ` removes the module directory -- [ ] `bread modules remove ` with `--yes` skips confirmation -- [ ] `bread modules remove ` prints a clear error and exits 1 -- [ ] `bread modules list` reads all installed module manifests -- [ ] `bread modules list` shows daemon-reported status when daemon is running -- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) -- [ ] `bread modules update` re-installs all github-sourced modules -- [ ] `bread modules update` skips local-path modules with a warning -- [ ] `bread modules info ` shows all manifest fields and daemon status - -### Sync — functional -- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields -- [ ] `bread sync init` errors if already initialized -- [ ] `bread sync push` creates the correct repo directory structure -- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo -- [ ] `bread sync push` copies each delegate path to `configs//` -- [ ] `bread sync push` writes package manifests to `packages/` -- [ ] `bread sync push` writes `machines/.toml` -- [ ] `bread sync push` creates a Git commit with a sensible message -- [ ] `bread sync push` pushes to the configured remote -- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 -- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` -- [ ] `bread sync pull` copies `configs/` entries back to their original locations -- [ ] `bread sync pull` calls `modules.reload` IPC after applying -- [ ] `bread sync pull --install-packages` runs package installs -- [ ] `bread sync pull` without `--install-packages` does not run package installs -- [ ] `bread sync status` shows local uncommitted changes -- [ ] `bread sync status` shows remote changes not yet pulled -- [ ] `bread sync status` prints `nothing to push — already up to date` when clean -- [ ] `bread sync machines` lists all `machines/*.toml` entries -- [ ] `bread sync init` without `--remote` prompts for URL interactively - -### Sync — error handling -- [ ] `bread sync push` without init prints clear error and exits 1 -- [ ] `bread sync pull` without init prints clear error and exits 1 -- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 -- [ ] Package manager not installed is warned and skipped, not a fatal error - -### Lua API -- [ ] `bread.machine.name()` returns machine name from sync.toml -- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist -- [ ] `bread.machine.tags()` returns array of tags -- [ ] `bread.machine.has_tag("x")` returns true/false correctly -- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs -- [ ] `bread.fs.read(path)` returns file content as string -- [ ] `bread.fs.read(nonexistent)` returns nil, does not error -- [ ] `bread.fs.exists(path)` returns correct bool -- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path -- [ ] All `bread.fs` paths handle `~` expansion - -### Udev vendor/product ID -- [ ] `vendor_id` and `product_id` fields are present in udev device events -- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` -- [ ] `bread events` output shows `vendor_id` and `product_id` when available - -### No regressions -- [ ] `bread reload` still works -- [ ] `bread state` still works -- [ ] `bread events` still works -- [ ] `bread doctor` still works -- [ ] `bread ping` still works -- [ ] `bread emit` still works -- [ ] Daemon starts cleanly with no existing `sync.toml` -- [ ] Daemon starts cleanly with a valid `sync.toml` -- [ ] All existing IPC methods still respond correctly - -### Code quality -- [ ] No hardcoded paths containing usernames or `/home/` -- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling -- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases -- [ ] All new public functions have doc comments -- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API - ---- - -## Implementation order - -Work in this order. Do not move to the next step until the current one compiles and its tests pass. - -1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) -2. Implement `SyncConfig` (load/save `sync.toml`) -3. Implement `bread sync init` -4. Implement Git backend in `bread-sync/src/git.rs` -5. Implement `bread sync push` (bread config only, no delegates or packages yet) -6. Implement delegate file handling -7. Implement package manifest generation -8. Implement `bread sync pull` -9. Implement `bread sync status`, `diff`, `machines` -10. Implement `bread modules install` (local path first, then GitHub) -11. Implement `bread modules remove`, `list`, `update`, `info` -12. Add `vendor_id`/`product_id` to udev adapter and `Device` type -13. Add `bread.machine` Lua API -14. Add `bread.fs` Lua API -15. Write all tests -16. Run full checklist — fix anything not passing -17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 72cecc3..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -263,12 +248,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "bitflags" version = "1.3.2" @@ -325,23 +304,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-sync" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "dirs", - "flate2", - "git2", - "reqwest", - "serde", - "serde_json", - "tar", - "tempfile", - "toml", -] - [[package]] name = "breadd" version = "0.1.0" @@ -377,12 +339,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "byteorder" version = "1.5.0" @@ -402,8 +358,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -413,20 +367,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -482,32 +422,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -517,15 +431,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -572,53 +477,12 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -742,52 +606,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -934,18 +758,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -954,45 +766,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1032,210 +810,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1288,12 +868,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1306,28 +880,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -1366,43 +918,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libgit2-sys" -version = "0.16.2+1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - [[package]] name = "libudev-sys" version = "0.1.4" @@ -1413,18 +928,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libz-sys" -version = "1.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -1443,12 +946,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "lock_api" version = "0.4.14" @@ -1525,22 +1022,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "0.8.11" @@ -1594,23 +1075,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1750,61 +1214,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "ordered-float" version = "2.10.1" @@ -1859,12 +1268,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1918,15 +1321,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1974,12 +1368,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -2025,17 +1413,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "regex" version = "1.12.3" @@ -2065,46 +1442,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - [[package]] name = "rtnetlink" version = "0.9.1" @@ -2166,27 +1503,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -2196,44 +1512,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2313,18 +1597,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "sha1" version = "0.10.6" @@ -2361,12 +1633,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -2399,12 +1665,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -2439,55 +1699,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2530,16 +1741,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tokio" version = "1.52.3" @@ -2569,29 +1770,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2644,12 +1822,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2711,12 +1883,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2764,24 +1930,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2794,12 +1942,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2822,15 +1964,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2855,61 +1988,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2944,16 +2022,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" @@ -2997,65 +2065,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -3222,16 +2237,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winsafe" version = "0.0.19" @@ -3332,22 +2337,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - [[package]] name = "xdg-home" version = "1.3.0" @@ -3358,29 +2347,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - [[package]] name = "zbus" version = "3.15.2" @@ -3468,60 +2434,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ee8711e..ab4e899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ members = [ "bread-shared", "breadd", - "bread-cli", - "bread-sync" + "bread-cli" ] resolver = "2" diff --git a/README.md b/README.md index ae1e55a..73512df 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,11 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter --- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - bread.exec("xset r rate 200 40") -end, { filter = function(event) return event.data.class == "keyboard" -end }) +end, function(event) + bread.exec("xset r rate 200 40") +end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 43c17a9..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,7 +9,6 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -17,9 +16,3 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" -dirs = "5.0" -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" -chrono = { version = "0.4", features = ["serde"] } -toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index d57890a..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,7 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -11,16 +10,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; -use bread_sync::{ - config::{bread_config_dir, sync_repo_path, SyncConfig}, - delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, - git, - machine::{list_machines, machine_name, MachineProfile}, - packages::snapshot_packages, -}; - -// ─── CLI structure ──────────────────────────────────────────────────────────── - #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -58,16 +47,8 @@ enum Commands { #[arg(long)] since: Option, }, - /// Manage installed Lua modules - Modules { - #[command(subcommand)] - action: ModulesAction, - }, - /// Sync system state to/from a Git remote - Sync { - #[command(subcommand)] - action: SyncAction, - }, + /// List loaded modules and status + Modules, /// List available profiles ProfileList, /// Activate a profile @@ -90,79 +71,6 @@ enum Commands { }, } -#[derive(Subcommand, Debug)] -enum ModulesAction { - /// Install a module from a source (github:user/repo[@ref] or /local/path) - Install { - source: String, - }, - /// Remove an installed module - Remove { - name: String, - /// Skip confirmation prompt - #[arg(long, short = 'y')] - yes: bool, - }, - /// List installed modules with status - List, - /// Update installed modules to latest - Update { - /// Update only this specific module - name: Option, - }, - /// Show detailed manifest info for a module - Info { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -enum SyncAction { - /// Initialize sync for this machine - Init { - /// Git remote URL - #[arg(long)] - remote: Option, - }, - /// Snapshot and push current state - Push { - /// Commit message - #[arg(long, short = 'm')] - message: Option, - }, - /// Pull and apply latest state from remote - Pull { - /// Also run package install commands - #[arg(long)] - install_packages: bool, - }, - /// Show what has changed since last push - Status, - /// Show file-level diff vs remote - Diff { - /// Diff against remote HEAD instead of working tree - #[arg(long)] - remote: bool, - }, - /// List known machines from sync repo - Machines, -} - -// ─── Module manifest ────────────────────────────────────────────────────────── - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ModuleManifest { - name: String, - version: String, - description: String, - author: String, - source: String, - #[serde(skip_serializing_if = "Option::is_none")] - installed_at: Option, -} - -// ─── Entry point ────────────────────────────────────────────────────────────── - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -173,65 +81,71 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; + let response = send_request(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { - let response = if let Some(path) = path { - send_request_or_die(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request_or_die(&socket, "state.dump", json!({})).await? - }; if *json { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_json(&response)?; } else { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { filter, json, fields, since } => { + Commands::Events { + filter, + json, + fields, + since, + } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules { action } => { - handle_modules(action, &socket).await?; - } - Commands::Sync { action } => { - handle_sync(action, &socket).await?; + Commands::Modules => { + let response = send_request(&socket, "modules.list", json!({})).await?; + print_json(&response)?; } Commands::ProfileList => { - let response = send_request_or_die(&socket, "profile.list", json!({})).await?; + let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request_or_die( - &socket, - "profile.activate", - json!({ "name": name }), - ) - .await?; + let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?; print_json(&response)?; } Commands::Emit { event, data } => { let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); - let response = send_request_or_die( + let response = send_request( &socket, "emit", - json!({ "event": event, "data": parsed }), + json!({ + "event": event, + "data": parsed, + }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request_or_die(&socket, "ping", json!({})).await?; + let response = send_request(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -242,699 +156,6 @@ async fn main() -> Result<()> { Ok(()) } -// ─── Modules sub-commands ───────────────────────────────────────────────────── - -async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { - match action { - ModulesAction::Install { source } => { - modules_install(source, socket).await?; - } - ModulesAction::Remove { name, yes } => { - modules_remove(name, *yes, socket).await?; - } - ModulesAction::List => { - modules_list(socket).await?; - } - ModulesAction::Update { name } => { - modules_update(name.as_deref(), socket).await?; - } - ModulesAction::Info { name } => { - modules_info(name, socket).await?; - } - } - Ok(()) -} - -async fn modules_install(source: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - if let Some(rest) = source.strip_prefix("github:") { - install_github_module(rest, source, &modules_dir)?; - } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { - let local_path = expand_tilde(source); - install_local_module(&local_path, &modules_dir)?; - } else { - eprintln!("bread: unknown source format '{source}'"); - eprintln!(" expected: github:user/repo[@ref] or /local/path"); - std::process::exit(1); - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { - let manifest_path = src.join("bread.module.toml"); - if !manifest_path.exists() { - eprintln!( - "bread: no bread.module.toml found at {}", - manifest_path.display() - ); - std::process::exit(1); - } - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(src, &dest)?; - - // Write updated manifest with installed_at - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { - let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { - (r, Some(v.to_string())) - } else { - (spec, None) - }; - - let (user, repo) = repo_spec - .split_once('/') - .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; - - let client = reqwest::blocking::Client::builder() - .user_agent("bread-cli/0.1") - .build()?; - - let resolved_ref = match git_ref { - Some(r) => r, - None => { - let url = format!("https://api.github.com/repos/{user}/{repo}"); - let resp: Value = client.get(&url).send()?.json()?; - 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/{resolved_ref}" - ); - let bytes = client.get(&tarball_url).send()?.bytes()?; - - // Extract to a temp dir - let tmp = tempfile_dir()?; - let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); - let mut archive = tar::Archive::new(gz); - archive.unpack(&tmp)?; - - // The tarball has a single top-level directory; find it - let extracted_dir = std::fs::read_dir(&tmp)? - .filter_map(|e| e.ok()) - .find(|e| e.path().is_dir()) - .map(|e| e.path()) - .ok_or_else(|| anyhow!("tarball contained no directory"))?; - - let manifest_path = extracted_dir.join("bread.module.toml"); - if !manifest_path.exists() { - let _ = std::fs::remove_dir_all(&tmp); - eprintln!( - "bread: no bread.module.toml found in github:{}/{} (ref {})", - user, repo, resolved_ref - ); - std::process::exit(1); - } - - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - manifest.source = source_str.to_string(); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(&extracted_dir, &dest)?; - - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - let _ = std::fs::remove_dir_all(&tmp); - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - if !yes { - eprint!("remove {name}? (y/n) "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!("cancelled"); - return Ok(()); - } - } - - std::fs::remove_dir_all(&module_dir)?; - println!("removed {name}"); - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_list(socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let manifests = scan_modules(&modules_dir)?; - - // Try to get daemon status - let daemon_modules = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()); - - for manifest in &manifests { - let status = daemon_modules - .as_ref() - .and_then(|mods| { - mods.iter().find(|m| { - m.get("name").and_then(Value::as_str) == Some(&manifest.name) - }) - }) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); - - println!( - " {:<20} {:<10} {:<12} {}", - manifest.name, manifest.version, status, manifest.source - ); - } - Ok(()) -} - -async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - let to_update: Vec = if let Some(name) = name { - let manifest = load_manifest(&modules_dir.join(name))?; - vec![manifest] - } else { - scan_modules(&modules_dir)? - }; - - for manifest in to_update { - if !manifest.source.starts_with("github:") { - eprintln!( - "warn: cannot update '{}' — local module, reinstall manually", - manifest.name - ); - continue; - } - let old_version = manifest.version.clone(); - let source = manifest.source.clone(); - let rest = source.trim_start_matches("github:"); - install_github_module(rest, &source, &modules_dir)?; - let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; - if new_manifest.version == old_version { - println!("{} already up to date", manifest.name); - } else { - println!( - "updated {} v{} → v{}", - manifest.name, old_version, new_manifest.version - ); - } - } - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_info(name: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - let manifest = load_manifest(&module_dir)?; - let status = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()) - .and_then(|mods| { - mods.iter() - .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .map(ToString::to_string) - }) - .unwrap_or_else(|| "unknown".to_string()); - - println!("name: {}", manifest.name); - println!("version: {}", manifest.version); - println!("description: {}", manifest.description); - println!("author: {}", manifest.author); - println!("source: {}", manifest.source); - println!( - "installed_at: {}", - manifest.installed_at.as_deref().unwrap_or("unknown") - ); - println!("status: {status}"); - Ok(()) -} - -// ─── Sync sub-commands ──────────────────────────────────────────────────────── - -async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { - match action { - SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, - SyncAction::Push { message } => sync_push(message.as_deref()).await?, - SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, - SyncAction::Status => sync_status().await?, - SyncAction::Diff { remote } => sync_diff(*remote).await?, - SyncAction::Machines => sync_machines().await?, - } - Ok(()) -} - -async fn sync_init(remote_arg: Option<&str>) -> Result<()> { - if SyncConfig::is_initialized()? { - eprintln!( - "bread: sync already initialized. Edit {} to reconfigure.", - bread_sync::config::config_path()?.display() - ); - std::process::exit(1); - } - - let remote_url = if let Some(url) = remote_arg { - url.to_string() - } else { - eprint!("Sync remote URL (git remote or path): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let url = input.trim().to_string(); - if url.is_empty() { - anyhow::bail!("remote URL is required"); - } - url - }; - - let default_hostname = hostname_or_unknown(); - eprint!("Machine name [{}]: ", default_hostname); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let machine_name = { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - default_hostname.clone() - } else { - trimmed - } - }; - - eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let tags: Vec = input - .trim() - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let cfg = SyncConfig { - remote: bread_sync::config::RemoteConfig { - url: Some(remote_url.clone()), - branch: "main".to_string(), - }, - machine: bread_sync::config::MachineConfig { - name: Some(machine_name.clone()), - tags, - }, - ..Default::default() - }; - cfg.save()?; - - // Validate remote if it looks like a URL - if !remote_url.starts_with('/') { - println!("remote does not exist yet — it will be created on first push"); - } - - println!("sync initialized:"); - println!(" machine: {machine_name}"); - println!(" remote: {remote_url}"); - Ok(()) -} - -async fn sync_push(message: Option<&str>) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - // Snapshot bread config - let bread_dir = bread_config_dir()?; - let bread_dest = repo_path.join("bread"); - sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; - - // Snapshot delegates - copy_delegates_to_repo(&cfg.delegates, &repo_path)?; - - // Snapshot packages - if cfg.packages.enabled { - snapshot_packages(&cfg.packages.managers, &repo_path)?; - } - - // Write machine profile - let profile = MachineProfile::new(&cfg)?; - profile.write_to_repo(&repo_path)?; - - // Stage all - git::stage_all(&repo)?; - - // Check for changes - if !git::has_changes(&repo)? { - println!("nothing to push — already up to date"); - return Ok(()); - } - - // Commit - let machine = machine_name(&cfg)?; - let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); - let commit_msg = message - .map(ToString::to_string) - .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); - git::commit(&repo, &commit_msg)?; - - // Set remote and push - if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} - git::push(&repo, "origin", &branch)?; - - println!("pushed: {commit_msg}"); - println!(" bread config: {}", bread_dir.display()); - if cfg.packages.enabled { - println!(" packages: {}", cfg.packages.managers.join(", ")); - } - Ok(()) -} - -async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - git::pull(&repo, "origin", &branch)?; - - // Restore bread config - let bread_src = repo_path.join("bread"); - let bread_dest = bread_config_dir()?; - if bread_src.exists() { - sync_dir(&bread_src, &bread_dest, &[])?; - } - - // Restore delegates - restore_delegates_from_repo(&cfg.delegates, &repo_path)?; - - // Package installs - if install_packages && cfg.packages.enabled { - run_package_installs(&repo_path, &cfg.packages.managers)?; - } else if cfg.packages.enabled { - let pkg_dir = repo_path.join("packages"); - if pkg_dir.exists() { - println!( - "note: run 'bread sync pull --install-packages' to install missing packages" - ); - } - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - - println!("pulled and applied latest state"); - Ok(()) -} - -async fn sync_status() -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not yet initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - let machine = machine_name(&cfg)?; - let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); - let last_push = git::last_commit_time(&repo); - - println!("bread sync status"); - println!(" machine {machine}"); - println!(" remote {remote_url}"); - println!(" last push {last_push}"); - - let local_changes = git::status_lines(&repo)?; - println!(); - println!("local changes (not yet pushed):"); - if local_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &local_changes { - println!(" {ch} {path}"); - } - } - - // Fetch to check remote - let _ = git::fetch(&repo, "origin"); - let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); - println!(); - println!("remote changes (not yet pulled):"); - if has_remote { - println!(" (run 'bread sync pull' to apply)"); - } else { - println!(" none"); - } - Ok(()) -} - -async fn sync_diff(show_remote: bool) -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - - let diff = if show_remote { - git::fetch(&repo, "origin")?; - git::diff_remote(&repo, "origin", &cfg.remote.branch)? - } else { - git::diff_workdir(&repo)? - }; - - if diff.is_empty() { - println!("no differences"); - } else { - print!("{diff}"); - } - Ok(()) -} - -async fn sync_machines() -> Result<()> { - let repo_path = sync_repo_path()?; - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - let machines = list_machines(&repo_path); - if machines.is_empty() { - println!("no machines found in sync repo"); - return Ok(()); - } - for m in machines { - let tags = if m.tags.is_empty() { - "(none)".to_string() - } else { - m.tags.join(", ") - }; - println!( - " {:<20} last sync: {:<20} tags: {}", - m.name, m.last_sync, tags - ); - } - Ok(()) -} - -fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - let f = pkg_dir.join("pacman.txt"); - if f.exists() { - let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); - let status = std::process::Command::new("sudo") - .args(["pacman", "-S", "--needed"]) - .args(&names) - .status(); - if let Err(e) = status { - eprintln!("warn: pacman install failed: {e}"); - } - } - } - "pip" => { - let f = pkg_dir.join("pip.txt"); - if f.exists() { - let status = std::process::Command::new("pip") - .args(["install", "--user", "-r"]) - .arg(&f) - .status(); - if let Err(e) = status { - eprintln!("warn: pip install failed: {e}"); - } - } - } - "npm" => { - let f = pkg_dir.join("npm.txt"); - if f.exists() { - let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); - for name in names { - let _ = std::process::Command::new("npm") - .args(["install", "-g", &name]) - .status(); - } - } - } - "cargo" => { - let f = pkg_dir.join("cargo.txt"); - if f.exists() { - let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); - for entry in entries { - let name = entry.split_whitespace().next().unwrap_or(&entry); - let _ = std::process::Command::new("cargo") - .args(["install", name]) - .status(); - } - } - } - _ => {} - } - } - Ok(()) -} - -// ─── Helper functions ───────────────────────────────────────────────────────── - -fn require_sync_config() -> Result { - if !SyncConfig::is_initialized()? { - eprintln!("bread: sync not initialized. Run: bread sync init"); - std::process::exit(1); - } - SyncConfig::load() -} - -fn modules_directory() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow!("cannot determine config directory"))?; - let dir = config_dir.join("bread").join("modules"); - std::fs::create_dir_all(&dir)?; - Ok(dir) -} - -fn scan_modules(modules_dir: &Path) -> Result> { - let mut out = Vec::new(); - if !modules_dir.exists() { - return Ok(out); - } - for entry in std::fs::read_dir(modules_dir)? { - let entry = entry?; - if !entry.path().is_dir() { - continue; - } - if let Ok(manifest) = load_manifest(&entry.path()) { - out.push(manifest); - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(out) -} - -fn load_manifest(module_dir: &Path) -> Result { - let path = module_dir.join("bread.module.toml"); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) -} - -fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let dest_path = dest.join(entry.file_name()); - if entry.path().is_dir() { - copy_dir_all(&entry.path(), &dest_path)?; - } else { - std::fs::copy(entry.path(), dest_path)?; - } - } - Ok(()) -} - -fn tempfile_dir() -> Result { - let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); - std::fs::create_dir_all(&tmp)?; - Ok(tmp) -} - -fn hostname_or_unknown() -> String { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -// ─── IPC helpers ────────────────────────────────────────────────────────────── - fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -942,26 +163,6 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } -/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. -async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { - match send_request(socket, method, params).await { - Ok(v) => Ok(v), - Err(err) => { - let msg = err.to_string(); - if msg.contains("No such file") - || msg.contains("Connection refused") - || msg.contains("not found") - { - eprintln!( - "bread: daemon is not running. Start it with: systemctl --user start breadd" - ); - std::process::exit(1); - } - Err(err) - } - } -} - async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -994,8 +195,7 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = - send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; if let Some(list) = replay.as_array() { for item in list { if raw_json { @@ -1012,7 +212,9 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { "filter": filter }, + "params": { + "filter": filter, + }, }); write_half @@ -1028,11 +230,10 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } + Ok(()) } -// ─── Display helpers ────────────────────────────────────────────────────────── - fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -1096,11 +297,15 @@ fn format_timestamp(ms: u64) -> String { let secs = ms / 1000; let millis = ms % 1000; + // SAFETY: localtime_r is thread-safe. We pass a valid pointer to a + // zeroed tm struct and read the result only after the call returns. let local_secs = unsafe { let mut tm: libc::tm = std::mem::zeroed(); let t = secs as libc::time_t; libc::localtime_r(&t, &mut tm); - tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 + tm.tm_hour as u64 * 3600 + + tm.tm_min as u64 * 60 + + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -1140,11 +345,16 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } + + // Debounce: drain any follow-up events that arrive within 150ms. + // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - let response = send_request_or_die(socket, "modules.reload", json!({})).await?; + + let response = send_request(socket, "modules.reload", json!({})).await?; print_reload(&response); } + Ok(()) } @@ -1177,11 +387,7 @@ fn render_doctor(health: &Value) { let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown"); let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); - println!( - " daemon {} (pid {})", - if ok { "✓ running" } else { "✗ unreachable" }, - pid - ); + println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -1252,7 +458,11 @@ async fn send_request_with_stream( } fn config_directory() -> PathBuf { - dirs::config_dir() - .map(|d| d.join("bread")) - .unwrap_or_else(|| PathBuf::from(".config/bread")) + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread"); + } + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(".config/bread"); + } + PathBuf::from(".config/bread") } diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml deleted file mode 100644 index c4860dc..0000000 --- a/bread-sync/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "bread-sync" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true -toml = "0.8" -chrono = { version = "0.4", features = ["serde"] } -dirs = "5.0" -git2 = { version = "0.18", features = ["vendored-libgit2"] } -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" - -[dev-dependencies] -tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md deleted file mode 100644 index 079b8d6..0000000 --- a/bread-sync/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# bread-sync - -Sync and module management library for the Bread reactive desktop automation daemon. - -Provides: -- `SyncConfig` — load/save `~/.config/bread/sync.toml` -- Git backend (via git2) for push/pull of bread config to a remote repository -- Delegate file handling — copy arbitrary config files into the sync repo -- Package manifest generation for pacman/pip/npm/cargo -- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs deleted file mode 100644 index d0b7506..0000000 --- a/bread-sync/src/config.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncConfig { - #[serde(default)] - pub remote: RemoteConfig, - #[serde(default)] - pub machine: MachineConfig, - #[serde(default)] - pub packages: PackagesConfig, - #[serde(default)] - pub delegates: DelegatesConfig, -} - -/// Git remote configuration. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RemoteConfig { - pub url: Option, - #[serde(default = "default_branch")] - pub branch: String, -} - -fn default_branch() -> String { - "main".to_string() -} - -/// Machine identity — name comes from here, falls back to hostname. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct MachineConfig { - pub name: Option, - #[serde(default)] - pub tags: Vec, -} - -/// Which package managers to snapshot. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PackagesConfig { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_managers")] - pub managers: Vec, -} - -impl Default for PackagesConfig { - fn default() -> Self { - Self { - enabled: true, - managers: default_managers(), - } - } -} - -fn default_true() -> bool { - true -} - -fn default_managers() -> Vec { - vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] -} - -/// Config file delegation — which extra paths to include in the sync repo. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DelegatesConfig { - /// Absolute or `~`-prefixed paths to copy into `configs//`. - #[serde(default)] - pub include: Vec, - /// Glob patterns to exclude when copying. - #[serde(default)] - pub exclude: Vec, -} - -impl SyncConfig { - /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. - pub fn load() -> Result { - let path = config_path()?; - if !path.exists() { - return Ok(Self::default()); - } - let raw = std::fs::read_to_string(&path)?; - let cfg: Self = toml::from_str(&raw)?; - Ok(cfg) - } - - /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. - pub fn save(&self) -> Result<()> { - let path = config_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. - pub fn is_initialized() -> Result { - Ok(config_path()?.exists()) - } -} - -/// Path to `~/.config/bread/sync.toml`. -pub fn config_path() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread").join("sync.toml")) -} - -/// Path to `~/.local/share/bread/sync-repo/`. -pub fn sync_repo_path() -> Result { - let data_dir = dirs::data_local_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; - Ok(data_dir.join("bread").join("sync-repo")) -} - -/// Path to `~/.config/bread/`. -pub fn bread_config_dir() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread")) -} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs deleted file mode 100644 index aadab3b..0000000 --- a/bread-sync/src/delegates.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Result; - -use crate::config::DelegatesConfig; - -/// Expand `~` in a path string to the user's home directory. -pub fn expand_tilde(path: &str) -> PathBuf { - if let Some(rest) = path.strip_prefix("~/") { - dirs::home_dir() - .map(|h| h.join(rest)) - .unwrap_or_else(|| PathBuf::from(path)) - } else if path == "~" { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) - } else { - PathBuf::from(path) - } -} - -/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. -fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { - let rel = path.strip_prefix(base).unwrap_or(path); - let rel_str = rel.to_string_lossy(); - for pattern in excludes { - if glob_matches(pattern, &rel_str) { - return true; - } - } - false -} - -/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. -/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are -/// absent in `src` (rsync `--delete` behaviour). -pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - std::fs::create_dir_all(dest)?; - copy_recursive(src, src, dest, excludes)?; - delete_extra(src, dest)?; - Ok(()) -} - -fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - - if is_excluded(root, &src_path, excludes) { - continue; - } - - let file_name = entry.file_name(); - let dest_path = dest.join(&file_name); - - if src_path.is_dir() { - std::fs::create_dir_all(&dest_path)?; - copy_recursive(root, &src_path, &dest_path, excludes)?; - } else { - std::fs::copy(&src_path, &dest_path)?; - } - } - Ok(()) -} - -/// Remove files/dirs from `dest` that don't exist in `src`. -fn delete_extra(src: &Path, dest: &Path) -> Result<()> { - if !dest.exists() { - return Ok(()); - } - for entry in std::fs::read_dir(dest)? { - let entry = entry?; - let dest_path = entry.path(); - let file_name = entry.file_name(); - let src_path = src.join(&file_name); - if !src_path.exists() { - if dest_path.is_dir() { - std::fs::remove_dir_all(&dest_path)?; - } else { - std::fs::remove_file(&dest_path)?; - } - } - } - Ok(()) -} - -/// Copy each `include` path into `/configs//`. -pub fn copy_delegates_to_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - std::fs::create_dir_all(&configs_dir)?; - - for raw_path in &cfg.include { - let src = expand_tilde(raw_path); - if !src.exists() { - tracing_warn(&format!( - "delegate path does not exist, skipping: {}", - src.display() - )); - continue; - } - let basename = src - .file_name() - .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; - let dest = configs_dir.join(basename); - if src.is_dir() { - sync_dir(&src, &dest, &cfg.exclude)?; - } else { - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Restore each delegate path from `/configs//` to its original location. -pub fn restore_delegates_from_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - - for raw_path in &cfg.include { - let dest = expand_tilde(raw_path); - let basename = match dest.file_name() { - Some(n) => n.to_os_string(), - None => continue, - }; - let src = configs_dir.join(&basename); - if !src.exists() { - continue; - } - if src.is_dir() { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - sync_dir(&src, &dest, &[])?; - } else { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Simple glob match for `**` and `*` patterns against a path string. -fn glob_matches(pattern: &str, path: &str) -> bool { - glob_match_bytes(pattern.as_bytes(), path.as_bytes()) -} - -fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { - if pattern.is_empty() { - return text.is_empty(); - } - - // `**` matches any sequence including path separators - if pattern.starts_with(b"**") { - let rest = &pattern[2..]; - if rest.is_empty() { - return true; - } - // skip leading separator in rest - let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; - for offset in 0..=text.len() { - if glob_match_bytes(rest, &text[offset..]) { - return true; - } - } - return false; - } - - match pattern[0] { - b'*' => { - let mut offset = 0; - loop { - if glob_match_bytes(&pattern[1..], &text[offset..]) { - return true; - } - if offset == text.len() { - break; - } - offset += 1; - } - false - } - b'?' => { - if text.is_empty() { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - ch => { - if text.first().copied() != Some(ch) { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - } -} - -fn tracing_warn(msg: &str) { - // Use eprintln since tracing may not be configured in library context - eprintln!("warn: {msg}"); -} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs deleted file mode 100644 index 581efbc..0000000 --- a/bread-sync/src/git.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::path::Path; - -use anyhow::{anyhow, Result}; - -/// Open an existing repo or initialise a new one at `path`. -pub fn init_or_open(path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - Ok(git2::Repository::open(path)?) - } else { - std::fs::create_dir_all(path)?; - Ok(git2::Repository::init(path)?) - } -} - -/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. -pub fn clone_or_open(url: &str, path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - return Ok(git2::Repository::open(path)?); - } - let mut builder = git2::build::RepoBuilder::new(); - let mut fetch_opts = git2::FetchOptions::new(); - fetch_opts.remote_callbacks(make_callbacks()); - builder.fetch_options(fetch_opts); - std::fs::create_dir_all(path)?; - Ok(builder.clone(url, path)?) -} - -/// Stage every tracked and untracked change (equivalent to `git add -A`). -pub fn stage_all(repo: &git2::Repository) -> Result<()> { - let mut index = repo.index()?; - index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; - // Remove entries for deleted files - index.update_all(["*"].iter(), None)?; - index.write()?; - Ok(()) -} - -/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). -pub fn has_changes(repo: &git2::Repository) -> Result { - let mut index = repo.index()?; - index.read(false)?; - - // New repo with no commits yet - if repo.head().is_err() { - return Ok(index.len() > 0); - } - - let head = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; - Ok(diff.deltas().count() > 0) -} - -/// Commit all staged changes with `message`. Returns the new commit OID. -pub fn commit(repo: &git2::Repository, message: &str) -> Result { - let mut index = repo.index()?; - let tree_id = index.write_tree()?; - let tree = repo.find_tree(tree_id)?; - let sig = repo.signature().unwrap_or_else(|_| { - git2::Signature::now("bread", "bread@localhost").expect("signature") - }); - - let oid = if let Ok(head) = repo.head() { - let parent = head.peel_to_commit()?; - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? - } else { - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? - }; - Ok(oid) -} - -/// Push `branch` to `remote_name` (defaults to "origin"). -pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::PushOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.push( - &[&format!("refs/heads/{branch}:refs/heads/{branch}")], - Some(&mut opts), - )?; - Ok(()) -} - -/// Fetch from `remote_name` without merging. -pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::FetchOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.fetch(&[] as &[&str], Some(&mut opts), None)?; - Ok(()) -} - -/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. -pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - fetch(repo, remote_name)?; - - let fetch_head = repo - .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) - .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; - - let analysis = repo.merge_analysis(&[&fetch_commit])?; - if analysis.0.is_up_to_date() { - return Ok(()); - } - if !analysis.0.is_fast_forward() { - return Err(anyhow!( - "sync conflict — resolve manually in {}", - repo.workdir() - .unwrap_or_else(|| Path::new("?")) - .display() - )); - } - - // Fast-forward: update HEAD and checkout - let head_ref = repo.find_reference("HEAD")?; - let resolved = head_ref.resolve()?; - let refname = resolved.name().unwrap_or("HEAD").to_string(); - repo.find_reference(&refname)? - .set_target(fetch_commit.id(), "fast-forward")?; - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; - Ok(()) -} - -/// Add a remote named `name` pointing at `url`, or update it if it already exists. -pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { - if repo.find_remote(name).is_ok() { - repo.remote_set_url(name, url)?; - } else { - repo.remote(name, url)?; - } - Ok(()) -} - -/// Return working-tree diff against HEAD as a unified diff string. -pub fn diff_workdir(repo: &git2::Repository) -> Result { - let mut buf = Vec::new(); - if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { - let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return diff between HEAD and `remote/branch` as a unified diff string. -pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_tree = repo - .find_reference(&remote_ref) - .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? - .peel_to_tree()?; - let local_tree = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; - let mut buf = Vec::new(); - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return a list of `(status_char, path)` for the working tree. -pub fn status_lines(repo: &git2::Repository) -> Result> { - let statuses = repo.statuses(None)?; - let mut out = Vec::new(); - for entry in statuses.iter() { - let path = entry.path().unwrap_or("?").to_string(); - let flag = entry.status(); - let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { - 'A' - } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { - 'D' - } else { - 'M' - }; - out.push((ch, path)); - } - Ok(out) -} - -/// Returns true if the local HEAD is behind the remote. -pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let Ok(remote_ref) = repo.find_reference(&remote_ref) else { - return false; - }; - let Ok(remote_commit) = remote_ref.peel_to_commit() else { - return false; - }; - let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return false; - }; - remote_commit.id() != local_commit.id() -} - -/// Timestamp of the HEAD commit (or "never"). -pub fn last_commit_time(repo: &git2::Repository) -> String { - let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return "never".to_string(); - }; - let ts = commit.time().seconds(); - let dt = chrono::DateTime::::from_timestamp(ts, 0) - .unwrap_or_else(chrono::Utc::now); - dt.format("%Y-%m-%d %H:%M:%S").to_string() -} - -fn is_bare(path: &Path) -> bool { - path.join("HEAD").exists() && path.join("objects").exists() -} - -fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, allowed_types| { - if allowed_types.contains(git2::CredentialType::SSH_KEY) { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - } else if allowed_types.contains(git2::CredentialType::DEFAULT) { - git2::Cred::default() - } else { - Err(git2::Error::from_str( - "no supported credential type (SSH agent or default)", - )) - } - }); - callbacks -} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs deleted file mode 100644 index 454a78a..0000000 --- a/bread-sync/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod config; -pub mod delegates; -pub mod git; -pub mod machine; -pub mod packages; - -pub use config::{ - bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, - RemoteConfig, SyncConfig, -}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs deleted file mode 100644 index e4e4bb1..0000000 --- a/bread-sync/src/machine.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::config::SyncConfig; - -/// Machine profile persisted to `/machines/.toml`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MachineProfile { - pub name: String, - pub hostname: String, - pub tags: Vec, - pub last_sync: String, // RFC 3339 -} - -impl MachineProfile { - pub fn new(cfg: &SyncConfig) -> Result { - let host = hostname()?; - let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); - Ok(Self { - name, - hostname: host, - tags: cfg.machine.tags.clone(), - last_sync: Utc::now().to_rfc3339(), - }) - } - - /// Write profile to `/machines/.toml`. - pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { - let machines_dir = repo_root.join("machines"); - std::fs::create_dir_all(&machines_dir)?; - let path = machines_dir.join(format!("{}.toml", self.name)); - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Load from `/machines/.toml`. - pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { - let path = repo_root.join("machines").join(format!("{name}.toml")); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) - } -} - -/// List all machine profiles in `/machines/`. -pub fn list_machines(repo_root: &Path) -> Vec { - let machines_dir = repo_root.join("machines"); - let Ok(entries) = std::fs::read_dir(&machines_dir) else { - return Vec::new(); - }; - entries - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) - .filter_map(|e| { - std::fs::read_to_string(e.path()) - .ok() - .and_then(|raw| toml::from_str::(&raw).ok()) - }) - .collect() -} - -/// Returns the machine name from sync.toml, falling back to hostname. -pub fn machine_name(cfg: &SyncConfig) -> Result { - if let Some(name) = cfg.machine.name.as_deref() { - return Ok(name.to_string()); - } - hostname() -} - -/// Returns the machine tags from sync.toml. -pub fn machine_tags(cfg: &SyncConfig) -> Vec { - cfg.machine.tags.clone() -} - -/// Returns true if `tag` is in the machine's tag list. -pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { - cfg.machine.tags.iter().any(|t| t == tag) -} - -fn hostname() -> Result { - // Try /etc/hostname first (no subprocess) - if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { - let trimmed = raw.trim().to_string(); - if !trimmed.is_empty() { - return Ok(trimmed); - } - } - // Fall back to hostname(1) - let out = std::process::Command::new("hostname") - .output() - .map_err(anyhow::Error::from)?; - let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; - Ok(s.trim().to_string()) -} - -#[allow(dead_code)] -fn format_last_sync(dt: &DateTime) -> String { - dt.format("%Y-%m-%d %H:%M").to_string() -} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs deleted file mode 100644 index 333e0aa..0000000 --- a/bread-sync/src/packages.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use anyhow::Result; - -/// Write package manifests to `/packages/`. -/// Skips package managers that are not installed (warns instead of erroring). -pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - std::fs::create_dir_all(&pkg_dir)?; - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - if let Some(content) = run_pacman() { - std::fs::write(pkg_dir.join("pacman.txt"), content)?; - } else { - eprintln!("warn: pacman not found, skipping package snapshot"); - } - } - "pip" => { - if let Some(content) = run_pip() { - std::fs::write(pkg_dir.join("pip.txt"), content)?; - } else { - eprintln!("warn: pip not found, skipping package snapshot"); - } - } - "npm" => { - if let Some(content) = run_npm() { - std::fs::write(pkg_dir.join("npm.txt"), content)?; - } else { - eprintln!("warn: npm not found, skipping package snapshot"); - } - } - "cargo" => { - if let Some(content) = run_cargo() { - std::fs::write(pkg_dir.join("cargo.txt"), content)?; - } else { - eprintln!("warn: cargo not found, skipping package snapshot"); - } - } - other => { - eprintln!("warn: unknown package manager '{other}', skipping"); - } - } - } - Ok(()) -} - -/// Parse a `pacman.txt` snapshot into a list of package names. -pub fn parse_pacman(content: &str) -> Vec { - content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() -} - -/// Parse a `pip.txt` (freeze format) snapshot into package names. -pub fn parse_pip(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) - .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) - .collect() -} - -/// Parse an `npm.txt` (parseable) snapshot into package names. -pub fn parse_npm(content: &str) -> Vec { - content - .lines() - .skip(1) // first line is the npm global prefix path - .filter(|l| !l.trim().is_empty()) - .filter_map(|l| { - Path::new(l.trim()) - .file_name() - .and_then(|n| n.to_str()) - .map(ToString::to_string) - }) - .collect() -} - -/// Parse `cargo install --list` output into `name version` lines. -pub fn parse_cargo(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) - .filter_map(|l| { - // Format: `name v1.2.3 (...):` or `name v1.2.3:` - let parts: Vec<&str> = l.splitn(2, ' ').collect(); - if parts.len() == 2 { - let name = parts[0]; - let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); - Some(format!("{name} {version}")) - } else { - None - } - }) - .collect() -} - -fn run_pacman() -> Option { - let output = Command::new("pacman").args(["-Qe"]).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_pip() -> Option { - let output = Command::new("pip") - .args(["list", "--user", "--format=freeze"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_npm() -> Option { - let output = Command::new("npm") - .args(["list", "-g", "--depth=0", "--parseable"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_cargo() -> Option { - let output = Command::new("cargo") - .args(["install", "--list"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs deleted file mode 100644 index ce76abf..0000000 --- a/bread-sync/tests/sync.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index 36189a0..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,25 +22,20 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let payloads = match enumerate_with_udev(&self.subsystems) { - Ok(p) => p, - Err(_) => scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| json!({ - "action": "add", - "id": d.id, - "name": d.name, - "subsystem": d.subsystem, - })) - .collect(), - }; + let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { + scan_devices(&self.subsystems).unwrap_or_default() + }); - for payload in payloads { + for device in devices { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload, + payload: json!({ + "action": "add", + "id": device.id, + "name": device.name, + "subsystem": device.subsystem, + }), timestamp: now_unix_ms(), }) .await?; @@ -169,7 +164,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -189,38 +184,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(json!({ - "action": "add", - "id": id, - "name": name, - "subsystem": subsystem, - "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), - "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), - "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), - "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), - "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), - "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), - "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), - "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), - "id_model": dev_prop_str(&dev, "ID_MODEL"), - })); + out.push(ScannedDevice { + id, + name, + subsystem, + }); } Ok(out) } -fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { - dev.property_value(key) - .and_then(|v| v.to_str()) - .map(|v| v == "1") - .unwrap_or(false) -} - -fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { - dev.property_value(key) - .map(|v| v.to_string_lossy().to_string()) -} - fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From e39b1683988a3c30d0f5ee2c87667593069959ce Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 00:20:45 +0800 Subject: [PATCH 3/4] feat: add bread-sync module for snapshot and restore functionality - Introduced `bread-sync` module with core functionalities for syncing system state via Git. - Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles. - Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`. - Created comprehensive tests for sync operations, package parsing, and machine profile management. - Enhanced `udev` adapter to include vendor and product IDs for scanned devices. - Updated state engine to handle module clearing commands. - Introduced Lua integration for accessing machine information and file system operations. - Improved packaging documentation for Arch Linux and systemd service setup. --- .gitignore | 6 +- Cargo.lock | 1103 ++++++++++++++++++++++++++++++- Cargo.toml | 8 +- README.md | 316 +++++++-- bread-cli/Cargo.toml | 12 + bread-cli/src/lib.rs | 2 + bread-cli/src/main.rs | 730 +++++++++++++++++++- bread-cli/src/modules_mgmt.rs | 175 +++++ bread-cli/tests/modules.rs | 133 ++++ bread-sync/Cargo.toml | 18 + bread-sync/README.md | 88 +++ bread-sync/src/config.rs | 135 ++++ bread-sync/src/delegates.rs | 109 +++ bread-sync/src/git.rs | 366 ++++++++++ bread-sync/src/lib.rs | 9 + bread-sync/src/machine.rs | 79 +++ bread-sync/src/packages.rs | 144 ++++ bread-sync/tests/sync.rs | 257 +++++++ breadd/src/adapters/udev.rs | 29 + breadd/src/core/state_engine.rs | 16 + breadd/src/core/types.rs | 4 + breadd/src/ipc/mod.rs | 33 + breadd/src/lua/mod.rs | 170 ++++- packaging/README.md | 50 +- packaging/arch/README.md | 30 +- 25 files changed, 3930 insertions(+), 92 deletions(-) create mode 100644 bread-cli/src/lib.rs create mode 100644 bread-cli/src/modules_mgmt.rs create mode 100644 bread-cli/tests/modules.rs create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.gitignore b/.gitignore index 0c56659..a253843 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ target/ Overview.md DAEMON.md +LUA_RUNTIME.md +CLAUDE_SPEC.md .claude CLAUDE.md -<<<<<<< HEAD .github -======= -.github/ ->>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/Cargo.lock b/Cargo.lock index 313315f..c04bd41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -288,12 +309,20 @@ version = "0.1.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", + "chrono", "clap", + "dirs", + "flate2", "libc", "notify", + "reqwest", "serde", "serde_json", + "tar", + "tempfile", "tokio", + "toml", ] [[package]] @@ -304,6 +333,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +384,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,6 +420,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -422,6 +489,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +524,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,12 +579,53 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -606,12 +749,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -758,6 +941,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -766,11 +961,51 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1045,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -868,6 +1301,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1319,28 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,6 +1379,43 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -928,6 +1426,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -946,6 +1456,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1022,6 +1538,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1607,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1763,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1268,6 +1872,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1321,6 +1931,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,6 +1987,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1413,6 +2038,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -1442,6 +2078,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rtnetlink" version = "0.9.1" @@ -1503,6 +2179,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1512,12 +2209,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,6 +2326,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1633,6 +2374,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,6 +2412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1699,6 +2452,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1741,6 +2543,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1770,6 +2582,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2657,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2724,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,6 +2777,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1942,6 +2807,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2835,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2868,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2957,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +3010,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2237,6 +3235,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -2337,6 +3345,22 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2347,6 +3371,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -2434,6 +3481,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ab4e899..8216be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync", ] resolver = "2" @@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] } anyhow = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +git2 = "0.18" +dirs = "5.0" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +glob = "0.3" diff --git a/README.md b/README.md index 73512df..adbff67 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically. -> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development. +> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use. --- @@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that: Your automation lives in Lua. You subscribe to events, read state, and call APIs: ```lua -bread.on("bread.device.dock.connected", function() +local M = bread.module({ name = "dock", version = "1.0.0" }) + +bread.on("bread.device.dock.connected", function(event) bread.profile.activate("desk") bread.exec("waybar --config ~/.config/waybar/desk.jsonc") + bread.notify("Dock connected", { urgency = "low" }) end) -bread.on("bread.device.dock.disconnected", function() +bread.on("bread.device.dock.disconnected", function(event) bread.profile.activate("default") end) + +return M ``` --- @@ -40,6 +45,7 @@ end) breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision bread-cli/ CLI frontend — talks to breadd over a Unix socket bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource +bread-sync/ Sync engine — snapshot and restore system state via a Git remote packaging/ Arch PKGBUILD and systemd user service ``` @@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system bash scripts/install.sh ``` -Or do it step by step: +Or step by step: ```bash cargo build --release @@ -141,19 +147,16 @@ default_urgency = "normal" notify_send_path = "notify-send" [modules] -builtin = true # load built-in modules (monitors, devices, etc.) -disable = [] # list of built-in module names to disable +builtin = true # load built-in modules (monitors, devices, workspaces, binds) +disable = [] # list of built-in module names to disable ``` -Your automation lives in `~/.config/bread/init.lua`: +Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`: ```lua -- ~/.config/bread/init.lua -require("modules.devices") -require("modules.workspaces") - -bread.on("bread.system.startup", function() +bread.on("bread.system.startup", function(event) bread.profile.activate("default") end) ``` @@ -165,19 +168,144 @@ end) All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. ```bash +# Daemon +bread ping # Check daemon connectivity +bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health + +# Lua runtime bread reload # Hot-reload all Lua modules bread reload --watch # Watch config dir and reload on changes + +# State and events bread state # Dump full runtime state as JSON bread events # Stream live normalized events bread events --filter bread.device.* # Stream filtered events bread events --since 60 # Replay events from the last 60 seconds -bread modules # List loaded modules and status +bread emit # Manually fire an event (for testing) + +# Profiles bread profile-list # List defined profiles bread profile-activate # Activate a named profile -bread emit --data '{}' # Manually fire an event (for testing) -bread ping # Check daemon connectivity -bread health # Daemon version, uptime, PID -bread doctor # Diagnose daemon and module health + +# Modules +bread modules list # List installed modules and daemon status +bread modules install github:user/repo # Install from GitHub +bread modules install /local/path # Install from a local directory +bread modules remove # Remove an installed module +bread modules update [name] # Re-install one or all GitHub-sourced modules +bread modules info # Show full manifest and daemon status + +# Sync +bread sync init # Initialize sync for this machine +bread sync push # Snapshot and push current state to remote +bread sync pull # Pull and apply latest state from remote +bread sync pull --install-packages # Also install packages from snapshot +bread sync status # Show what has changed since last push +bread sync diff # Show file-level diff vs last commit +bread sync diff --remote # Show diff vs remote +bread sync machines # List known machines from sync repo +``` + +--- + +## Module system + +Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest. + +### Installing modules + +```bash +# From GitHub (downloads latest release tarball) +bread modules install github:someuser/bread-wifi + +# From a local path +bread modules install ~/src/my-module + +# From a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 +``` + +### Writing a module + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← required manifest + └── init.lua ← entry point +``` + +`bread.module.toml`: +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +`init.lua`: +```lua +local M = bread.module({ name = "wifi", version = "1.0.0" }) + +bread.on("bread.network.connected", function(event) + bread.log("Network up: " .. (event.data.interface or "unknown")) +end) + +return M +``` + +--- + +## Sync system + +Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Push current state +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Check what's pending +bread sync status +``` + +Configure what gets synced in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +The sync repo stores: + +``` +sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) ``` --- @@ -209,28 +337,48 @@ Events follow the namespace convention `bread...`. | `bread.network.connected` | Network interface came online | | `bread.network.disconnected` | Network interface went offline | | `bread.profile.activated` | Profile switched | +| `bread.notify.sent` | Desktop notification dispatched | --- ## Lua API +### Modules + +Every module file must declare itself. The declaration is used for dependency ordering and status tracking. + +```lua +local M = bread.module({ + name = "my-module", + version = "1.0.0", + after = { "bread.devices" }, -- load after this module +}) + +-- ... module body ... + +return M +``` + ### Events ```lua --- Subscribe to an event; returns a numeric ID +-- Subscribe to events; returns a subscription ID local id = bread.on("bread.monitor.connected", function(event) - print(event.data.name) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) end) -- Unsubscribe by ID bread.off(id) --- Subscribe once, then auto-unsubscribe +-- Subscribe once, auto-unsubscribe after first delivery bread.once("bread.system.startup", function(event) - -- runs exactly once + bread.profile.activate("default") end) --- Subscribe with a predicate filter +-- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) return event.data.class == "keyboard" end, function(event) @@ -241,18 +389,33 @@ end) bread.emit("mymodule.something", { key = "value" }) ``` +Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character): +```lua +bread.on("bread.device.*", handler) -- matches bread.device.dock.connected +bread.on("bread.device.**", handler) -- matches any depth under bread.device +``` + ### State ```lua --- Read a value from runtime state by dot-separated path +-- Read from runtime state by dot-separated path local monitors = bread.state.get("monitors") -local workspace = bread.state.get("active_workspace") -local power = bread.state.get("power") -local devices = bread.state.get("devices") +local online = bread.state.get("network.online") --- Watch a state key and fire on changes -bread.state.watch("active_workspace", function(new, old) - print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +-- Typed shorthands +local monitors = bread.state.monitors() +local workspace = bread.state.active_workspace() +local window = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() + +-- Watch a state path for changes +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end end) ``` @@ -266,39 +429,99 @@ bread.profile.activate("default") ### Execution and notifications ```lua --- Fire-and-forget: returns immediately, process runs in background +-- Fire-and-forget shell command bread.exec("kitty") --- Desktop notification -bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +-- Desktop notification (uses notify-send) +bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" }) +bread.notify("Simple message") -- title defaults to "bread" ``` ### Timers ```lua -- Run once after a delay (ms) -bread.after(500, function() +local id = bread.after(500, function() bread.exec("some-delayed-command") end) --- Run on a repeating interval (ms); returns a timer ID +-- Run on a repeating interval (ms) local id = bread.every(60000, function() bread.log("tick") end) + +-- Cancel either kind bread.cancel(id) -- Debounce a rapidly-firing handler local fn = bread.debounce(200, function(event) reconfigure_monitors() end) +bread.on("bread.monitor.*", fn) +``` + +### Wait (inside coroutines) + +```lua +-- Yield until a matching event arrives +local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) +if event then + -- dock arrived within 5 seconds +end +``` + +### Machine and filesystem + +```lua +-- Machine identity (from sync.toml, falls back to hostname) +local name = bread.machine.name() +local tags = bread.machine.tags() -- array of strings +local ok = bread.machine.has_tag("laptop") + +-- Filesystem helpers (~ is expanded) +bread.fs.write("~/.config/some/file", "content") +local content = bread.fs.read("~/.config/some/file") -- nil if not found +local exists = bread.fs.exists("~/some/path") +local abs = bread.fs.expand("~/some/path") ``` ### Logging ```lua -bread.log("Module loaded") -bread.warn("Unexpected state") -bread.error("Something failed") +bread.log("Module loaded") -- info level +bread.warn("Unexpected state") -- warn level +bread.error("Something failed") -- error level +``` + +### Hyprland bindings + +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +-- Query compositor state +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Subscribe to raw Hyprland events (bypass normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw is the unparsed string from Hyprland's event socket +end) +``` + +### Module-scoped storage + +Survives hot reload; does not survive daemon restart. + +```lua +M.store.set("last_profile", "docked") +local p = M.store.get("last_profile") -- "docked" ``` --- @@ -317,9 +540,24 @@ Response: { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } ``` -Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`. +Available methods: -`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. +| Method | Description | +|--------|-------------| +| `ping` | Connectivity check | +| `health` | Version, uptime, PID, adapter status | +| `state.get` | Read a value from `RuntimeState` by dotted key path | +| `state.dump` | Return the full `RuntimeState` as JSON | +| `modules.list` | List all loaded modules and their status | +| `modules.reload` | Hot-reload the Lua runtime | +| `profile.list` | List defined profiles | +| `profile.activate` | Switch active profile | +| `events.subscribe` | Upgrade connection to streaming mode | +| `events.replay` | Replay buffered events from the last N ms | +| `emit` | Inject a synthetic event into the pipeline | +| `sync.status` | Return sync initialization state and machine info | + +`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects. --- @@ -327,7 +565,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 69a2c49..7d40088 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -7,12 +7,24 @@ edition = "2021" name = "bread" path = "src/main.rs" +[lib] +name = "bread_cli" +path = "src/lib.rs" + [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true anyhow.workspace = true +chrono.workspace = true +dirs.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" +toml = "0.8" +reqwest = { version = "0.11", features = ["json"] } +flate2 = "1.0" +tar = "0.4" +tempfile.workspace = true diff --git a/bread-cli/src/lib.rs b/bread-cli/src/lib.rs new file mode 100644 index 0000000..72bcce2 --- /dev/null +++ b/bread-cli/src/lib.rs @@ -0,0 +1,2 @@ +/// Module management (install, remove, list, update, info). +pub mod modules_mgmt; diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0ca91df..eadd679 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,9 +1,16 @@ -use anyhow::Result; +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, + SyncRepo, +}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; -use std::io; +use std::io::{self, Write as IoWrite}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -47,8 +54,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,14 +86,70 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ModulesCommand { + /// Install a module from a source + Install { + /// Source: github:user/repo[@ref] or /path/to/dir + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, + /// List all installed modules + List, + /// Update one or all installed modules + Update { + /// Module name (omit to update all) + name: Option, + }, + /// Show full manifest details for a module + Info { name: String }, +} + +#[derive(Subcommand, Debug)] +enum SyncCommand { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Custom commit message + #[arg(long)] + message: Option, + }, + /// Pull and apply latest state + Pull { + /// Also install packages from manifest + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs last commit (or vs remote with --remote) + Diff { + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let socket = daemon_socket_path(); - match &cli.command { + match cli.command { Commands::Reload { watch } => { - if *watch { + if watch { watch_reload(&socket).await?; } else { let response = send_request(&socket, "modules.reload", json!({})).await?; @@ -86,19 +157,14 @@ async fn main() -> Result<()> { } } Commands::State { path, json } => { - if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; + let response = if let Some(ref path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + if json { print_json(&response)?; } else { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } @@ -108,22 +174,25 @@ async fn main() -> Result<()> { fields, since, } => { - stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; + stream_events(&socket, filter, json, fields, since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { subcommand } => { + handle_modules_cmd(subcommand, &socket).await?; + } + Commands::Sync { subcommand } => { + handle_sync_cmd(subcommand, &socket).await?; } Commands::ProfileList => { let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?; + let response = + send_request(&socket, "profile.activate", json!({ "name": name })).await?; print_json(&response)?; } Commands::Emit { event, data } => { - let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); + let parsed = serde_json::from_str::(&data).unwrap_or_else(|_| json!({})); let response = send_request( &socket, "emit", @@ -144,7 +213,7 @@ async fn main() -> Result<()> { print_json(&response)?; } Commands::Doctor { json } => { - if *json { + if json { let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { @@ -156,6 +225,580 @@ async fn main() -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Module subcommands +// --------------------------------------------------------------------------- + +async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { + let mods_dir = modules_mgmt::modules_dir(); + + match cmd { + ModulesCommand::Install { source } => { + let manifest = + install_module(&source, &mods_dir).await?; + println!("installed {} v{}", manifest.name, manifest.version); + try_daemon_reload(socket).await; + } + + ModulesCommand::Remove { name, yes } => { + let module_dir = mods_dir.join(&name); + if !module_dir.exists() { + eprintln!("bread: module '{}' is not installed", name); + std::process::exit(1); + } + if !yes { + print!("remove {}? (y/n): ", name); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + modules_mgmt::remove_module(&name, &mods_dir)?; + println!("removed {}", name); + try_daemon_reload(socket).await; + } + + ModulesCommand::List => { + let modules = modules_mgmt::list_modules(&mods_dir)?; + // Try to get daemon module status + let daemon_statuses = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let status = v.get("status").and_then(Value::as_str)?.to_string(); + Some((name, status)) + }) + .collect::>(), + Err(_) => std::collections::HashMap::new(), + }; + for m in &modules { + let status = daemon_statuses + .get(&m.name) + .map(String::as_str) + .unwrap_or("unknown"); + println!(" {:20} {:10} {:10} {}", m.name, m.version, status, m.source); + } + } + + ModulesCommand::Update { name } => { + let targets: Vec<_> = if let Some(n) = name { + vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?] + } else { + modules_mgmt::list_modules(&mods_dir)? + }; + + let mut updated_any = false; + for manifest in targets { + if manifest.source.starts_with("github:") { + let old_ver = manifest.version.clone(); + let new_manifest = + install_module(&manifest.source, &mods_dir).await?; + if new_manifest.version == old_ver { + println!("{} already up to date", manifest.name); + } else { + println!("updated {} v{} → v{}", manifest.name, old_ver, new_manifest.version); + updated_any = true; + } + } else { + eprintln!( + "cannot update local module '{}' — reinstall manually", + manifest.name + ); + } + } + if updated_any { + try_daemon_reload(socket).await; + } + } + + ModulesCommand::Info { name } => { + let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?; + let status = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .and_then(|arr| { + arr.iter() + .find(|v| v.get("name").and_then(Value::as_str) == Some(&m.name)) + .and_then(|v| v.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()), + Err(_) => "unknown".to_string(), + }; + println!("name: {}", m.name); + println!("version: {}", m.version); + println!("description: {}", m.description); + println!("author: {}", m.author); + println!("source: {}", m.source); + println!("installed_at: {}", m.installed_at); + println!("status: {}", status); + } + } + Ok(()) +} + +async fn install_module( + source: &str, + mods_dir: &std::path::Path, +) -> Result { + match modules_mgmt::parse_source(source)? { + modules_mgmt::InstallSource::LocalPath(path) => { + modules_mgmt::install_from_local(&path, source, mods_dir) + } + modules_mgmt::InstallSource::GitHub { user, repo, git_ref } => { + install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await + } + } +} + +async fn install_from_github( + user: &str, + repo: &str, + git_ref: Option<&str>, + source_str: &str, + mods_dir: &Path, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let ref_to_use = match git_ref { + Some(r) => r.to_string(), + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client + .get(&url) + .send() + .await + .context("failed to reach GitHub API")? + .json() + .await + .context("failed to parse GitHub API response")?; + resp.get("default_branch") + .and_then(Value::as_str) + .unwrap_or("main") + .to_string() + } + }; + + let tarball_url = + format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let bytes = client + .get(&tarball_url) + .send() + .await + .context("failed to download module archive")? + .bytes() + .await + .context("failed to read module archive")?; + + let tmp = tempfile::tempdir()?; + let mut archive = + tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + archive.unpack(tmp.path())?; + + // GitHub extracts to a single subdirectory (e.g. "user-repo-sha/") + let root = std::fs::read_dir(tmp.path())? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?; + + modules_mgmt::install_from_local(&root, source_str, mods_dir) +} + +/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. +async fn try_daemon_reload(socket: &Path) { + match send_request(socket, "modules.reload", json!({})).await { + Ok(_) => {} + Err(_) => { + eprintln!("note: daemon not running; reload manually with 'bread reload'"); + } + } +} + +// --------------------------------------------------------------------------- +// Sync subcommands +// --------------------------------------------------------------------------- + +async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { + let cfg_dir = bread_config_dir(); + + match cmd { + SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?, + SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?, + SyncCommand::Pull { install_packages } => { + cmd_sync_pull(&cfg_dir, install_packages, socket).await? + } + SyncCommand::Status => cmd_sync_status(&cfg_dir).await?, + SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?, + SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?, + } + Ok(()) +} + +async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { + let sync_toml = cfg_dir.join("sync.toml"); + if sync_toml.exists() { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + sync_toml.display() + ); + std::process::exit(1); + } + + let remote_url = match remote { + Some(u) => u, + None => { + print!("Sync remote URL (git remote or path): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + line.trim().to_string() + } + }; + + let default_hostname = machine::hostname(); + print!("Machine name [{}]: ", default_hostname); + io::stdout().flush()?; + let mut name_line = String::new(); + io::stdin().read_line(&mut name_line)?; + let machine_name = { + let t = name_line.trim(); + if t.is_empty() { + default_hostname + } else { + t.to_string() + } + }; + + print!("Machine tags (comma-separated, e.g. mobile,battery): "); + io::stdout().flush()?; + let mut tags_line = String::new(); + io::stdin().read_line(&mut tags_line)?; + let tags: Vec = tags_line + .trim() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + + let config = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: remote_url.clone(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: machine_name.clone(), + tags, + }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + }; + config.save(cfg_dir)?; + + // If it looks like a URL (not a local path), check if it exists + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + println!(" remote: {}", remote_url); + println!(" config: {}", cfg_dir.join("sync.toml").display()); + Ok(()) +} + +async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + // Clone or open the local sync repo + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir( + cfg_dir, + &bread_dest, + &[ + // Don't recurse into the sync repo itself + ".git".to_string(), + ], + )?; + + // Snapshot delegate configs + let configs_dir = repo_path.join("configs"); + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() { + let dst = configs_dir.join(basename); + delegates::sync_dir(src_path, &dst, &config.delegates.exclude)?; + } + } + + // Snapshot packages + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + if let Err(e) = packages::snapshot(manager, &dest_file) { + eprintln!("bread: warning: package snapshot for {} failed: {}", manager, e); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + let profile = + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); + profile.write(&machines_dir)?; + + // Set remote and commit + repo.set_remote("origin", &config.remote.url)?; + let commit_msg = message.unwrap_or_else(|| { + format!( + "sync: {} {}", + config.machine.name, + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ") + ) + }); + + if repo.commit(&commit_msg)?.is_none() { + println!("nothing to push — already up to date"); + return Ok(()); + } + + repo.push("origin", &config.remote.branch)?; + + println!("pushed sync for {}", config.machine.name); + println!(" bread config: {}", cfg_dir.display()); + if !config.delegates.include.is_empty() { + println!(" delegates: {}", config.delegates.include.len()); + } + if config.packages.enabled { + println!(" packages: {}", config.packages.managers.join(", ")); + } + Ok(()) +} + +async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + match repo.pull("origin", &config.remote.branch) { + Ok(()) => {} + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } + + // Apply bread/ → ~/.config/bread/ + let bread_src = repo_path.join("bread"); + if bread_src.exists() { + delegates::sync_dir(&bread_src, cfg_dir, &[])?; + } + + // Apply configs/ entries back to their original locations + let configs_dir = repo_path.join("configs"); + if configs_dir.exists() { + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, dst_path) in &delegate_paths { + let src = configs_dir.join(basename); + if src.exists() { + delegates::sync_dir(&src, dst_path, &config.delegates.exclude)?; + } + } + } + + // Package installs + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + if install_packages { + run_package_installs(&packages_dir, &config.packages.managers)?; + } else { + // Check if packages differ + let has_package_files = config.packages.managers.iter().any(|m| { + packages_dir.join(format!("{m}.txt")).exists() + }); + if has_package_files { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + } + + // Notify daemon + try_daemon_reload(socket).await; + + println!("applied sync for {}", config.machine.name); + Ok(()) +} + +async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + println!("bread sync status"); + println!(" not yet pushed"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + // Fetch remote refs without merging + let _ = repo.fetch("origin", &config.remote.branch); + + let last_push = repo + .last_commit_time() + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "never".to_string()); + + println!("bread sync status"); + println!(" machine {}", config.machine.name); + println!(" remote {}", config.remote.url); + println!(" last push {}", last_push); + + let local_changes = repo.local_changes()?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; + println!(); + println!("remote changes (not yet pulled):"); + if remote_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &remote_changes { + println!(" {} {}", ch, path); + } + } + + Ok(()) +} + +async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: sync repo not initialized. Run: bread sync push"); + std::process::exit(1); + } + + let repo = SyncRepo::open(&repo_path)?; + + let diff = if vs_remote { + repo.set_remote("origin", &config.remote.url)?; + let _ = repo.fetch("origin", &config.remote.branch); + repo.remote_diff("origin", &config.remote.branch)? + } else { + repo.working_diff()? + }; + + print!("{}", diff); + Ok(()) +} + +async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { + let _ = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + let machines_dir = repo_path.join("machines"); + + let profiles = machine::MachineProfile::list(&machines_dir)?; + for p in &profiles { + let tags = if p.tags.is_empty() { + String::new() + } else { + format!(" tags: {}", p.tags.join(", ")) + }; + println!(" {:20} last sync: {}{}", p.name, &p.last_sync[..16], tags); + } + Ok(()) +} + +fn load_sync_config(cfg_dir: &Path) -> Result { + match SyncConfig::load(cfg_dir) { + Ok(c) => Ok(c), + Err(_) => { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + } +} + +fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> { + for manager in managers { + let file = packages_dir.join(format!("{manager}.txt")); + if !file.exists() { + continue; + } + let content = std::fs::read_to_string(&file)?; + match manager.as_str() { + "pacman" => { + let pkgs = packages::parse_pacman(&content); + if pkgs.is_empty() { + continue; + } + let mut cmd = std::process::Command::new("sudo"); + cmd.args(["pacman", "-S", "--needed"]).args(&pkgs); + let _ = cmd.status(); + } + "pip" => { + let mut cmd = std::process::Command::new("pip"); + cmd.args(["install", "--user", "-r"]) + .arg(file.to_str().unwrap_or("")); + let _ = cmd.status(); + } + "npm" => { + let pkgs = packages::parse_npm(&content); + for pkg in pkgs { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); + } + } + "cargo" => { + let pkgs = packages::parse_cargo(&content); + for pkg in pkgs { + let _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); + } + } + _ => {} + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers (shared with original commands) +// --------------------------------------------------------------------------- + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -164,7 +807,18 @@ fn daemon_socket_path() -> PathBuf { } async fn send_request(socket: &Path, method: &str, params: Value) -> Result { - let stream = UnixStream::connect(socket).await?; + let stream = UnixStream::connect(socket).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::ConnectionRefused + { + anyhow::anyhow!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ) + } else { + e.into() + } + })?; + let (read_half, mut write_half) = stream.into_split(); let request = json!({ "id": "1", @@ -195,7 +849,9 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + let replay = + send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })) + .await?; if let Some(list) = replay.as_array() { for item in list { if raw_json { @@ -303,9 +959,7 @@ fn format_timestamp(ms: u64) -> String { let mut tm: libc::tm = std::mem::zeroed(); let t = secs as libc::time_t; libc::localtime_r(&t, &mut tm); - tm.tm_hour as u64 * 3600 - + tm.tm_min as u64 * 60 - + tm.tm_sec as u64 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -346,8 +1000,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { continue; } - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} @@ -384,10 +1036,20 @@ fn render_doctor(health: &Value) { println!("bread doctor"); let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false); let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0); - let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown"); - let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); + let version = health + .get("version") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let uptime_ms = health + .get("uptime_ms") + .and_then(Value::as_u64) + .unwrap_or(0); let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); - println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); + println!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..17c0a7b --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,175 @@ +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Contents of `bread.module.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub author: String, + pub source: String, + pub installed_at: String, +} + +/// Parsed install source. +pub enum InstallSource { + GitHub { + user: String, + repo: String, + git_ref: Option, + }, + LocalPath(PathBuf), +} + +/// Parse a source string into an `InstallSource`. +pub fn parse_source(source: &str) -> Result { + if let Some(rest) = source.strip_prefix("github:") { + let (repo_part, ref_part) = rest + .split_once('@') + .map(|(r, v)| (r, Some(v.to_string()))) + .unwrap_or((rest, None)); + let (user, repo) = repo_part.split_once('/').ok_or_else(|| { + anyhow::anyhow!( + "bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'", + source + ) + })?; + Ok(InstallSource::GitHub { + user: user.to_string(), + repo: repo.to_string(), + git_ref: ref_part, + }) + } else if source.starts_with('/') + || source.starts_with("./") + || source.starts_with("../") + || source.starts_with('~') + { + let expanded = bread_sync::config::expand_path(source); + Ok(InstallSource::LocalPath(expanded)) + } else { + bail!( + "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + source + ) + } +} + +/// Install a module from a local directory into `modules_dir`. +/// `source_str` is the original source string recorded in the manifest. +pub fn install_from_local(src: &Path, source_str: &str, modules_dir: &Path) -> Result { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + bail!( + "bread: no bread.module.toml found in {}", + src.display() + ); + } + + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let mut manifest: ModuleManifest = + toml::from_str(&raw).context("failed to parse bread.module.toml")?; + + manifest.source = source_str.to_string(); + manifest.installed_at = Utc::now().to_rfc3339(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + fs::remove_dir_all(&dest) + .with_context(|| format!("failed to remove existing module at {}", dest.display()))?; + } + copy_dir(src, &dest)?; + + // Rewrite the manifest with the updated fields. + let manifest_dest = dest.join("bread.module.toml"); + let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?; + fs::write(&manifest_dest, out) + .with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?; + + Ok(manifest) +} + +/// Remove a module directory from `modules_dir`. +pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> { + let module_dir = modules_dir.join(name); + if !module_dir.exists() { + bail!("bread: module '{}' is not installed", name); + } + fs::remove_dir_all(&module_dir) + .with_context(|| format!("failed to remove {}", module_dir.display())) +} + +/// List all installed modules in `modules_dir`. +pub fn list_modules(modules_dir: &Path) -> Result> { + if !modules_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(modules_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("bread.module.toml"); + if manifest_path.exists() { + if let Ok(m) = read_manifest_file(&manifest_path) { + out.push(m); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Read a module manifest by name. +pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result { + let manifest_path = modules_dir.join(name).join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: module '{}' is not installed", name); + } + read_manifest_file(&manifest_path) +} + +/// Read and parse a `bread.module.toml` file. +pub fn read_manifest_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse module manifest") +} + +/// Returns the default modules directory. +pub fn modules_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread").join("modules"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread").join("modules"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join("bread") + .join("modules"); + } + PathBuf::from(".config/bread/modules") +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path) + .with_context(|| format!("failed to copy {} to {}", src_path.display(), dst_path.display()))?; + } + } + Ok(()) +} diff --git a/bread-cli/tests/modules.rs b/bread-cli/tests/modules.rs new file mode 100644 index 0000000..d05374c --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,133 @@ +use bread_cli::modules_mgmt; +use std::fs; +use tempfile::TempDir; + +/// Helper: create a minimal valid module directory in `dir` with given name. +fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf { + let module_dir = dir.join(name); + fs::create_dir_all(&module_dir).unwrap(); + let manifest = format!( + r#"name = "{name}" +version = "{version}" +description = "Test module" +author = "test" +source = "/tmp/test" +installed_at = "" +"# + ); + fs::write(module_dir.join("bread.module.toml"), manifest).unwrap(); + fs::write(module_dir.join("init.lua"), "-- test\n").unwrap(); + module_dir +} + +#[test] +fn install_from_local_succeeds_with_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "mymod", "1.2.3"); + let src = src_tmp.path().join("mymod"); + + let result = + modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path()); + + assert!(result.is_ok(), "install failed: {:?}", result.err()); + let manifest = result.unwrap(); + assert_eq!(manifest.name, "mymod"); + assert_eq!(manifest.version, "1.2.3"); + + // Module directory must exist in modules dir + assert!(modules_tmp.path().join("mymod").exists()); + assert!(modules_tmp.path().join("mymod").join("bread.module.toml").exists()); + assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); +} + +#[test] +fn install_from_local_fails_without_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + // No bread.module.toml in src + let src = src_tmp.path(); + fs::write(src.join("init.lua"), "-- no manifest\n").unwrap(); + + let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("bread.module.toml"), + "expected error about bread.module.toml, got: {msg}" + ); +} + +#[test] +fn remove_deletes_module_directory() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "delme", "0.1.0"); + + // Verify it exists before removal + assert!(modules_tmp.path().join("delme").exists()); + + let result = modules_mgmt::remove_module("delme", modules_tmp.path()); + assert!(result.is_ok(), "remove failed: {:?}", result.err()); + assert!(!modules_tmp.path().join("delme").exists()); +} + +#[test] +fn remove_nonexistent_errors() { + let modules_tmp = TempDir::new().unwrap(); + let result = modules_mgmt::remove_module("ghost", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("ghost"), "expected error mentioning module name, got: {msg}"); +} + +#[test] +fn list_reads_manifests_from_disk() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "alpha", "1.0.0"); + make_module_dir(modules_tmp.path(), "beta", "2.0.0"); + + // Add a non-module dir (no manifest) — should be ignored + fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap(); + + let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].name, "alpha"); + assert_eq!(modules[1].name, "beta"); +} + +#[test] +fn manifest_written_correctly_on_install() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "installtest", "3.0.0"); + let src = src_tmp.path().join("installtest"); + + let manifest = + modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path()) + .unwrap(); + + // All required fields must be present and non-empty + assert_eq!(manifest.name, "installtest"); + assert_eq!(manifest.version, "3.0.0"); + assert!(!manifest.description.is_empty()); + assert!(!manifest.author.is_empty()); + assert_eq!(manifest.source, "github:test/installtest"); + assert!(!manifest.installed_at.is_empty()); + + // installed_at must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at); + assert!( + parsed.is_ok(), + "installed_at '{}' is not valid RFC 3339", + manifest.installed_at + ); + + // Verify the on-disk manifest also has all fields + let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap(); + assert_eq!(on_disk.name, manifest.name); + assert_eq!(on_disk.installed_at, manifest.installed_at); + assert_eq!(on_disk.source, "github:test/installtest"); +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..232b592 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +git2.workspace = true +dirs.workspace = true +chrono.workspace = true +glob.workspace = true +toml = "0.8" +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..7d37899 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,88 @@ +# bread-sync + +Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote. + +## Purpose + +`bread-sync` provides the library backing `bread sync` commands. It handles: + +- **Git operations** — clone, commit, push, pull, fetch, diff via `git2` +- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages) +- **Delegate file sync** — rsync-style directory copy with glob excludes +- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo +- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp + +## Public API + +### `config` + +```rust +SyncConfig::load(config_dir: &Path) -> Result +SyncConfig::save(&self, config_dir: &Path) -> Result<()> +SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/ +bread_config_dir() -> PathBuf // ~/.config/bread/ +expand_path(path: &str) -> PathBuf // expands ~/ +``` + +### `git` + +```rust +SyncRepo::init(path: &Path) -> Result +SyncRepo::open(path: &Path) -> Result +SyncRepo::clone_from(url: &str, path: &Path) -> Result +SyncRepo::open_or_clone(url: &str, path: &Path) -> Result +SyncRepo::commit(&self, message: &str) -> Result> // None = nothing to commit +SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only +SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::is_clean(&self) -> Result +SyncRepo::local_changes(&self) -> Result> +SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result> +SyncRepo::working_diff(&self) -> Result +SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result +SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()> +SyncRepo::last_commit_time(&self) -> Option> +``` + +### `delegates` + +```rust +sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> +resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> +``` + +### `machine` + +```rust +MachineProfile::new(name: String, tags: Vec) -> MachineProfile +MachineProfile::write(&self, machines_dir: &Path) -> Result<()> +MachineProfile::read(machines_dir: &Path, name: &str) -> Result +MachineProfile::list(machines_dir: &Path) -> Result> +hostname() -> String +``` + +### `packages` + +```rust +snapshot(manager: &str, dest: &Path) -> Result // false = manager not found (non-fatal) +parse_pacman(content: &str) -> Vec +parse_pip(content: &str) -> Vec +parse_npm(content: &str) -> Vec +parse_cargo(content: &str) -> Vec +``` + +## Sync repo layout + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← snapshot of ~/.config/bread/ +├── configs/ +│ └── / ← delegate paths +├── machines/ +│ └── .toml ← per-machine profiles +└── packages/ + ├── pacman.txt + ├── pip.txt + ├── npm.txt + └── cargo.txt +``` diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..55a8dd3 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,135 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + pub remote: RemoteConfig, + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConfig { + pub url: String, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineConfig { + pub name: String, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub managers: Vec, +} + +fn default_true() -> bool { + true +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: vec![ + "pacman".to_string(), + "pip".to_string(), + "npm".to_string(), + "cargo".to_string(), + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load sync config from the given bread config directory. + pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("sync.toml"); + let raw = fs::read_to_string(&path) + .with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?; + toml::from_str(&raw).context("failed to parse sync.toml") + } + + /// Save sync config to the given bread config directory. + pub fn save(&self, config_dir: &Path) -> Result<()> { + let path = config_dir.join("sync.toml"); + fs::create_dir_all(config_dir)?; + let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`). + pub fn local_repo_path() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + return data_dir.join("bread").join("sync-repo"); + } + // Fallback using $HOME + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("bread") + .join("sync-repo"); + } + PathBuf::from(".local/share/bread/sync-repo") + } +} + +/// Returns the bread config directory (`~/.config/bread/`). +pub fn bread_config_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("bread"); + } + PathBuf::from(".config/bread") +} + +/// Expand `~` to the home directory in a path string. +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..2c59792 --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use glob::Pattern; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::expand_path; + +/// Copy all files from `src` into `dst`, mirroring the directory tree. +/// Files present in `dst` but not in `src` are deleted (rsync-style). +/// Files matching any `exclude` glob are skipped. +pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> { + let patterns: Vec = exclude + .iter() + .filter_map(|g| Pattern::new(g).ok()) + .collect(); + + fs::create_dir_all(dst)?; + sync_dir_inner(src, dst, src, &patterns) +} + +fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> { + // Remove files in dst that don't exist in src. + if dst.exists() { + for entry in fs::read_dir(dst)? { + let entry = entry?; + let rel = entry.path().strip_prefix(dst).unwrap_or(&entry.path()).to_path_buf(); + let src_counterpart = src.join(&rel); + if !src_counterpart.exists() { + let p = entry.path(); + if p.is_dir() { + let _ = fs::remove_dir_all(&p); + } else { + let _ = fs::remove_file(&p); + } + } + } + } + + if !src.exists() { + return Ok(()); + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let rel = src_path.strip_prefix(root).unwrap_or(&src_path); + + if is_excluded(rel, root, patterns) { + continue; + } + + let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path)); + + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + sync_dir_inner(&src_path, &dst_path, root, patterns)?; + } else { + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool { + let rel_str = rel.to_string_lossy(); + let file_name = rel + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + for pat in patterns { + // Match against full relative path or just filename + if pat.matches(&rel_str) || pat.matches(&file_name) { + return true; + } + // For directory-name patterns (e.g. "**/.git"), also check component names + if let Some(pat_str) = pat.as_str().strip_prefix("**/") { + for component in rel.components() { + if let std::path::Component::Normal(name) = component { + if Pattern::new(pat_str) + .map(|p| p.matches(&name.to_string_lossy())) + .unwrap_or(false) + { + return true; + } + } + } + } + } + false +} + +/// Resolve delegate paths from the config (expanding `~`). +pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { + includes + .iter() + .map(|s| { + let expanded = expand_path(s); + let basename = expanded + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.clone()); + (basename, expanded) + }) + .collect() +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..a3740f8 --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,366 @@ +use anyhow::{Context, Result}; +use git2::{ + build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, + Repository, Signature, StatusOptions, +}; +use std::path::{Path, PathBuf}; + +/// Wraps a git2 repository with sync-specific operations. +pub struct SyncRepo { + repo: Repository, + pub path: PathBuf, +} + +impl SyncRepo { + /// Open an existing repository at `path`. + pub fn open(path: &Path) -> Result { + let repo = Repository::open(path) + .with_context(|| format!("failed to open git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Clone `url` into `path`. + pub fn clone_from(url: &str, path: &Path) -> Result { + let fetch_opts = make_fetch_options(); + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fetch_opts); + let repo = builder + .clone(url, path) + .with_context(|| format!("failed to clone {} into {}", url, path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Open the repo at `path` if it exists; otherwise clone from `url`. + pub fn open_or_clone(url: &str, path: &Path) -> Result { + if path.exists() { + Self::open(path) + } else { + std::fs::create_dir_all(path)?; + Self::clone_from(url, path) + } + } + + /// Initialize a new empty repository at `path` with `main` as the initial branch. + pub fn init(path: &Path) -> Result { + std::fs::create_dir_all(path)?; + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts) + .with_context(|| format!("failed to init git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Stage all changes (equivalent to `git add -A`). + pub fn stage_all(&self) -> Result<()> { + let mut index = self.repo.index().context("failed to get git index")?; + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .context("failed to stage changes")?; + index.write().context("failed to write git index")?; + Ok(()) + } + + /// Create a commit. Returns `None` if there are no staged changes. + pub fn commit(&self, message: &str) -> Result> { + self.stage_all()?; + + let mut index = self.repo.index()?; + let tree_id = index.write_tree()?; + + // Check if tree matches current HEAD (nothing to commit) + if let Ok(head) = self.repo.head() { + if let Ok(head_commit) = head.peel_to_commit() { + if head_commit.tree_id() == tree_id { + return Ok(None); + } + } + } + + let tree = self.repo.find_tree(tree_id)?; + let sig = Signature::now("Bread Sync", "bread@localhost")?; + + let oid = match self.repo.head() { + Ok(head) => { + let parent = head.peel_to_commit()?; + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } + Err(_) => { + // First commit — no parents + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + } + }; + + Ok(Some(oid)) + } + + /// Push `branch` to `remote_name`. + pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + let mut push_opts = PushOptions::new(); + let callbacks = make_callbacks(); + push_opts.remote_callbacks(callbacks); + remote + .push(&[refspec.as_str()], Some(&mut push_opts)) + .context("git push failed")?; + Ok(()) + } + + /// Fetch `branch` from `remote_name` without merging. + pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + let mut fetch_opts = make_fetch_options(); + remote + .fetch(&[branch], Some(&mut fetch_opts), None) + .context("git fetch failed")?; + Ok(()) + } + + /// Fetch and fast-forward merge. Errors on non-fast-forward. + pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> { + self.fetch(remote_name, branch)?; + + let fetch_head = self + .repo + .find_reference("FETCH_HEAD") + .context("FETCH_HEAD not found after fetch")?; + let fetch_commit = self + .repo + .reference_to_annotated_commit(&fetch_head) + .context("failed to get annotated commit from FETCH_HEAD")?; + + let (analysis, _) = self + .repo + .merge_analysis(&[&fetch_commit]) + .context("merge analysis failed")?; + + if analysis.is_up_to_date() { + return Ok(()); + } + + if analysis.is_fast_forward() { + let target_id = fetch_commit.id(); + let ref_name = format!("refs/heads/{branch}"); + match self.repo.find_reference(&ref_name) { + Ok(mut r) => { + r.set_target(target_id, "fast-forward pull")?; + } + Err(_) => { + self.repo + .reference(&ref_name, target_id, true, "fast-forward pull")?; + } + } + self.repo.set_head(&ref_name)?; + self.repo + .checkout_head(Some(CheckoutBuilder::default().force())) + .context("checkout failed during pull")?; + Ok(()) + } else { + anyhow::bail!( + "bread: sync conflict — resolve manually in {}", + self.path.display() + ) + } + } + + /// Returns true if working tree has no uncommitted changes. + pub fn is_clean(&self) -> Result { + Ok(self.local_changes()?.is_empty()) + } + + /// Returns list of (status_char, path) for working-tree changes vs HEAD. + pub fn local_changes(&self) -> Result> { + let mut status_opts = StatusOptions::new(); + status_opts + .include_untracked(true) + .recurse_untracked_dirs(true); + + let statuses = self + .repo + .statuses(Some(&mut status_opts)) + .context("failed to get git status")?; + + let mut out = Vec::new(); + for entry in statuses.iter() { + let s = entry.status(); + let ch = if s.contains(git2::Status::INDEX_NEW) + || s.contains(git2::Status::WT_NEW) + { + 'A' + } else if s.contains(git2::Status::INDEX_DELETED) + || s.contains(git2::Status::WT_DELETED) + { + 'D' + } else { + 'M' + }; + if let Some(path) = entry.path() { + out.push((ch, path.to_string())); + } + } + Ok(out) + } + + /// Returns list of (status_char, path) for changes on remote not yet pulled. + pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result> { + // We compare HEAD to remote/branch + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = match self.repo.find_reference(&remote_ref) { + Ok(r) => r.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + let head_commit = match self.repo.head() { + Ok(h) => h.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + if head_commit == remote_oid { + return Ok(vec![]); + } + + let head_tree = self.repo.find_commit(head_commit)?.tree()?; + let remote_tree = self.repo.find_commit(remote_oid)?.tree()?; + + let diff = self + .repo + .diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None) + .context("failed to compute remote diff")?; + + let mut out = Vec::new(); + for delta in diff.deltas() { + let ch = match delta.status() { + git2::Delta::Added => 'A', + git2::Delta::Deleted => 'D', + _ => 'M', + }; + if let Some(path) = delta.new_file().path() { + out.push((ch, path.to_string_lossy().to_string())); + } + } + Ok(out) + } + + /// Return a unified diff string of working tree vs HEAD. + pub fn working_diff(&self) -> Result { + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + + let diff = self + .repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .context("failed to compute working diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format diff")?; + + Ok(out) + } + + /// Return a unified diff string between HEAD and remote branch HEAD. + pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = self + .repo + .find_reference(&remote_ref) + .and_then(|r| r.peel_to_commit()) + .map(|c| c.id()) + .ok(); + + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + let remote_tree = remote_oid + .and_then(|id| self.repo.find_commit(id).ok()) + .and_then(|c| c.tree().ok()); + + let diff = self + .repo + .diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None) + .context("failed to compute remote diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format remote diff")?; + + Ok(out) + } + + /// Set a named remote. + pub fn set_remote(&self, name: &str, url: &str) -> Result<()> { + let _ = self.repo.remote_delete(name); + self.repo + .remote(name, url) + .with_context(|| format!("failed to set remote {name}"))?; + Ok(()) + } + + /// Return the timestamp of the last commit, or None if no commits. + pub fn last_commit_time(&self) -> Option> { + let head = self.repo.head().ok()?; + let commit = head.peel_to_commit().ok()?; + let t = commit.time(); + // git2::Time uses seconds-from-epoch and offset-in-minutes + let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?; + Some(naive.with_timezone(&chrono::Local)) + } +} + +fn make_callbacks<'a>() -> RemoteCallbacks<'a> { + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); + } + Cred::default() + }); + cb +} + +fn make_fetch_options<'a>() -> FetchOptions<'a> { + let mut opts = FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + opts +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..4b89f1a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,9 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..325ef5a --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,79 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Machine profile stored in `machines/.toml` in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineProfile { + pub name: String, + pub hostname: String, + pub tags: Vec, + pub last_sync: String, // RFC 3339 +} + +impl MachineProfile { + /// Create a new profile for this machine. + pub fn new(name: String, tags: Vec) -> Self { + Self { + hostname: hostname(), + name, + tags, + last_sync: Utc::now().to_rfc3339(), + } + } + + /// Write this profile to `/.toml`. + pub fn write(&self, machines_dir: &Path) -> Result<()> { + fs::create_dir_all(machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Read a machine profile from `/.toml`. + pub fn read(machines_dir: &Path, name: &str) -> Result { + let path = machines_dir.join(format!("{name}.toml")); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse machine profile") + } + + /// List all machine profiles in `machines_dir`. + pub fn list(machines_dir: &Path) -> Result> { + if !machines_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(machines_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(profile) = toml::from_str::(&raw) { + out.push(profile); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } +} + +/// Return the system hostname. +pub fn hostname() -> String { + // Try gethostname via libc, fall back to environment variable. + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + return s.to_string(); + } + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..96ad7b3 --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,144 @@ +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Snapshot a package manager's installed packages and write to `dest`. +/// Returns true if the snapshot was written, false if the package manager +/// is not installed (warns instead of failing). +pub fn snapshot(manager: &str, dest: &Path) -> Result { + let content = match manager { + "pacman" => run_pacman()?, + "pip" => run_pip()?, + "npm" => run_npm()?, + "cargo" => run_cargo()?, + other => { + eprintln!("bread: unknown package manager '{}', skipping", other); + return Ok(false); + } + }; + + let Some(content) = content else { + eprintln!( + "bread: package manager '{}' not found, skipping", + manager + ); + return Ok(false); + }; + + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(dest, content)?; + Ok(true) +} + +/// Parse a pacman snapshot (one "name version" per line, space-separated) and +/// return a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +/// Parse a pip freeze snapshot and return package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + l.split("==") + .next() + .unwrap_or(l) + .split(">=") + .next() + .unwrap_or(l) + .trim() + .to_string() + }) + .collect() +} + +/// Parse npm global packages list (parseable format, one path per line). +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + // `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg + let name = Path::new(l) + .file_name() + .map(|n| n.to_string_lossy().to_string())?; + // Skip npm itself and the root node_modules + if name == "node_modules" { + return None; + } + Some(name) + }) + .collect() +} + +/// Parse cargo install list. +/// Format: "crate v1.2.3 (some-path):\n binary\n..." +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .map(|l| { + l.split_whitespace() + .next() + .unwrap_or(l) + .to_string() + }) + .collect() +} + +fn run_pacman() -> Result> { + match Command::new("pacman").arg("-Qe").output() { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pip() -> Result> { + // Try pip3 first, then pip + for cmd in ["pip3", "pip"] { + match Command::new(cmd) + .args(["list", "--user", "--format=freeze"]) + .output() + { + Ok(out) if out.status.success() => { + return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } + } + Ok(None) +} + +fn run_npm() -> Result> { + match Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_cargo() -> Result> { + match Command::new("cargo").args(["install", "--list"]).output() { + Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..484120c --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,257 @@ +use bread_sync::{ + config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, + delegates, machine, packages, SyncRepo, +}; +use std::fs; +use tempfile::TempDir; + +fn make_bare_repo(path: &std::path::Path) -> git2::Repository { + let mut opts = git2::RepositoryInitOptions::new(); + opts.bare(true); + opts.initial_head("main"); + git2::Repository::init_opts(path, &opts).unwrap() +} + +// Helper to create a git commit in a non-bare repo so we have initial state +fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { + let repo = SyncRepo::init(path).unwrap(); + fs::write(path.join(".gitkeep"), "").unwrap(); + repo.stage_all().unwrap(); + repo.commit("initial commit").unwrap(); + repo +} + +#[test] +fn sync_init_creates_toml_with_required_fields() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "testbox".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); + assert_eq!(loaded.remote.branch, "main"); + assert_eq!(loaded.machine.name, "testbox"); + assert_eq!(loaded.machine.tags, vec!["mobile"]); +} + +#[test] +fn sync_init_errors_if_already_initialized() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "box".to_string(), + tags: vec![], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + // Second load should succeed (init itself must check for existence externally) + // We test that load works + let result = SyncConfig::load(tmp.path()); + assert!(result.is_ok()); + // sync.toml now exists — the CLI checks this before calling save + assert!(tmp.path().join("sync.toml").exists()); +} + +#[test] +fn sync_push_creates_correct_directory_structure() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + // Create initial bare remote + let _bare = make_bare_repo(bare_tmp.path()); + + // Create local bread config + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); + + // Init local sync repo + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + // Snapshot bread dir + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + // Write machine profile + let machines_dir = repo_tmp.path().join("machines"); + let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); + profile.write(&machines_dir).unwrap(); + + // Commit and push + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify structure in local repo + assert!(repo_tmp.path().join("bread").exists()); + assert!(repo_tmp.path().join("bread").join("init.lua").exists()); + assert!(repo_tmp.path().join("machines").join("testbox.toml").exists()); +} + +#[test] +fn sync_push_snapshots_bread_config() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a more complex bread config + fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); + fs::write( + bread_cfg_tmp.path().join("modules/mymod/init.lua"), + "-- mymod", + ) + .unwrap(); + + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify files were copied + assert!(bread_dest.join("init.lua").exists()); + assert!(bread_dest.join("modules/mymod/init.lua").exists()); + + let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); + assert_eq!(content, "-- init"); +} + +#[test] +fn sync_pull_copies_files_from_repo() { + let bare_tmp = TempDir::new().unwrap(); + let local_tmp = TempDir::new().unwrap(); + let apply_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a local repo, add some files, push to bare + let repo = SyncRepo::init(local_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + + let bread_dest = local_tmp.path().join("bread"); + fs::create_dir_all(&bread_dest).unwrap(); + fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); + + repo.commit("sync: first push").unwrap(); + repo.push("origin", "main").unwrap(); + + // Now clone the bare repo and pull + let clone_tmp = TempDir::new().unwrap(); + let cloned = SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); + + // Apply bread/ to apply_tmp + let src = clone_tmp.path().join("bread"); + if src.exists() { + delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); + } + + assert!(apply_tmp.path().join("init.lua").exists()); + let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); + assert_eq!(content, "-- from sync"); +} + +#[test] +fn package_manifest_pacman_parses_output_correctly() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + let pkgs = packages::parse_pacman(input); + assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); +} + +#[test] +fn package_manifest_pip_parses_output_correctly() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + let pkgs = packages::parse_pip(input); + assert_eq!(pkgs, vec!["requests", "numpy", "black"]); +} + +#[test] +fn delegates_exclude_globs_filter_correctly() { + let src_tmp = TempDir::new().unwrap(); + let dst_tmp = TempDir::new().unwrap(); + + // Create files that should and shouldn't be copied + fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); + fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); + fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); + fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); + fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); + + let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; + delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); + + assert!(dst_tmp.path().join("lua/init.lua").exists()); + assert!(!dst_tmp.path().join(".git").exists()); + assert!(!dst_tmp.path().join("log.cache").exists()); +} + +#[test] +fn machine_profile_written_with_correct_fields() { + let machines_tmp = TempDir::new().unwrap(); + let profile = machine::MachineProfile::new( + "myhost".to_string(), + vec!["mobile".to_string(), "battery".to_string()], + ); + profile.write(machines_tmp.path()).unwrap(); + + let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); + assert_eq!(loaded.name, "myhost"); + assert_eq!(loaded.tags, vec!["mobile", "battery"]); + assert!(!loaded.hostname.is_empty()); + // last_sync must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); + assert!( + parsed.is_ok(), + "last_sync '{}' is not valid RFC 3339", + loaded.last_sync + ); +} + +#[test] +fn status_shows_no_changes_when_clean() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + let changes = repo.local_changes().unwrap(); + assert!( + changes.is_empty(), + "expected no local changes, got: {:?}", + changes + ); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn push_with_no_changes_returns_none() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + + // No new changes — commit should return None + let result = repo.commit("second commit").unwrap(); + assert!( + result.is_none(), + "expected None (nothing to commit), got: {:?}", + result + ); +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..c3aba56 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -101,6 +101,8 @@ struct ScannedDevice { id: String, name: String, subsystem: String, + vendor_id: Option, + product_id: Option, } async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { @@ -148,6 +150,8 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"), "id_vendor": prop_str(&event, "ID_VENDOR"), "id_model": prop_str(&event, "ID_MODEL"), + "vendor_id": prop_str(&event, "ID_VENDOR_ID"), + "product_id": prop_str(&event, "ID_MODEL_ID"), }), timestamp: now_unix_ms(), }; @@ -183,11 +187,19 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .or_else(|| dev.sysname().to_str().map(ToString::to_string)) .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); + let vendor_id = dev + .property_value("ID_VENDOR_ID") + .map(|v| v.to_string_lossy().to_string()); + let product_id = dev + .property_value("ID_MODEL_ID") + .map(|v| v.to_string_lossy().to_string()); out.push(ScannedDevice { id, name, subsystem, + vendor_id, + product_id, }); } @@ -203,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { "id": dev.id, "name": dev.name, "subsystem": dev.subsystem, + "vendor_id": dev.vendor_id, + "product_id": dev.product_id, }), timestamp: now_unix_ms(), } @@ -226,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("drm:{name}"), name, subsystem: "drm".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -242,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("input:{name}"), name, subsystem: "input".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -257,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("power_supply:{name}"), name, subsystem: "power_supply".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -269,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result> { let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { + let syspath = entry.path(); + let vendor_id = fs::read_to_string(syspath.join("idVendor")) + .ok() + .map(|s| s.trim().to_string()); + let product_id = fs::read_to_string(syspath.join("idProduct")) + .ok() + .map(|s| s.trim().to_string()); out.push(ScannedDevice { id: format!("usb:{name}"), name, subsystem: "usb".to_string(), + vendor_id, + product_id, }); } } diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index caea8dc..6e69e6a 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -36,6 +36,7 @@ pub enum StateCommand { id: SubscriptionId, }, ClearSubscriptions, + ClearModules, SetModuleStatus { name: String, status: ModuleLoadState, @@ -112,6 +113,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + pub fn set_module_status( &self, name: String, @@ -236,6 +241,9 @@ async fn handle_command( watches.clear(); subscription_count.store(0, Ordering::Relaxed); } + StateCommand::ClearModules => { + state.write().await.modules.clear(); + } StateCommand::SetModuleStatus { name, status, @@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), + vendor_id: data + .get("vendor_id") + .and_then(Value::as_str) + .map(ToString::to_string), + product_id: data + .get("product_id") + .and_then(Value::as_str) + .map(ToString::to_string), }); } else { state.devices.connected.retain(|d| d.id != id); diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 45ccfa5..119b7af 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -57,6 +57,10 @@ pub struct Device { pub name: String, pub class: DeviceClass, pub subsystem: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub vendor_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index fff3368..25fe66c 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,6 +267,39 @@ impl Server { "recent_errors": recent_errors, })) } + "sync.status" => { + let cfg_home = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| { + std::env::var("HOME") + .map(|h| std::path::PathBuf::from(h).join(".config")) + }) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let sync_path = cfg_home.join("bread").join("sync.toml"); + match std::fs::read_to_string(&sync_path) + .ok() + .and_then(|s| s.parse::().ok()) + { + Some(toml) => { + let machine = toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let remote = toml + .get("remote") + .and_then(|r| r.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Ok(json!({ + "initialized": true, + "machine": machine, + "remote": remote, + })) + } + None => Ok(json!({ "initialized": false })), + } + } "events.replay" => { let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); let cutoff = now_unix_ms().saturating_sub(since_ms); diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 228ac61..7caa9c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; +use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -250,6 +251,7 @@ impl LuaEngine { self.run_on_unload(); self.cancel_all_timers(); self.state_handle.clear_subscriptions(); + self.state_handle.clear_modules(); self.lua = Lua::new(); self.handlers .lock() @@ -837,6 +839,66 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; + // bread.machine — machine name and tags from sync.toml + let machine_tbl = self.lua.create_table()?; + + let name_fn = self.lua.create_function(|_lua, ()| { + Ok(lua_machine_name()) + })?; + machine_tbl.set("name", name_fn)?; + + let tags_fn = self.lua.create_function(|lua, ()| { + let tags = lua_machine_tags(); + let tbl = lua.create_table()?; + for (i, tag) in tags.iter().enumerate() { + tbl.set(i + 1, tag.clone())?; + } + Ok(tbl) + })?; + machine_tbl.set("tags", tags_fn)?; + + let has_tag_fn = self.lua.create_function(|_lua, tag: String| { + Ok(lua_machine_tags().contains(&tag)) + })?; + machine_tbl.set("has_tag", has_tag_fn)?; + + bread.set("machine", machine_tbl)?; + + // bread.fs — file system helpers + let fs_tbl = self.lua.create_table()?; + + let write_fn = self.lua.create_function(|_lua, (path, content): (String, String)| { + let expanded = lua_expand_path(&path); + if let Some(parent) = expanded.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LuaError::external(e.to_string()))?; + } + std::fs::write(&expanded, content) + .map_err(|e| LuaError::external(e.to_string())) + })?; + fs_tbl.set("write", write_fn)?; + + let read_fn = self.lua.create_function(|_lua, path: String| { + let expanded = lua_expand_path(&path); + match std::fs::read_to_string(&expanded) { + Ok(s) => Ok(Some(s)), + Err(_) => Ok(None), + } + })?; + fs_tbl.set("read", read_fn)?; + + let exists_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).exists()) + })?; + fs_tbl.set("exists", exists_fn)?; + + let expand_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).to_string_lossy().to_string()) + })?; + fs_tbl.set("expand", expand_fn)?; + + bread.set("fs", fs_tbl)?; + globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; @@ -927,7 +989,7 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = if let Some(source) = decl.source.as_deref() { + let result = if let Some(source) = decl.source { self.load_lua_source(source, &decl.name) } else { self.load_lua_file(&decl.path, &decl.name, decl.builtin) @@ -1296,16 +1358,31 @@ impl LuaEngine { Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) })?; + // Build a minimal bread stub: bread.module() captures the decl and aborts; + // all other bread.* accesses return a no-op callable so modules that call + // bread.log() or bread.fs.exists() before bread.module() don't crash during scanning. let bread = lua.create_table()?; bread.set("module", module_fn)?; lua.globals().set("bread", bread)?; + lua.load(r#" + local _noop = function(...) end + local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop } + local _noop_tbl = setmetatable({}, _noop_tbl_mt) + setmetatable(bread, { + __index = function(_, k) + if k == "module" then return rawget(bread, "module") end + return _noop_tbl + end + }) + "#).exec()?; let src = fs::read_to_string(path)?; let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + // bread.module() throws MODULE_DECL_ABORT to abort scanning early. + // mlua may wrap the error in CallbackError, so match on string content. if let Err(err) = result { - match err { - LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} - other => return Err(anyhow!(other.to_string())), + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); } } @@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St }); } +fn lua_expand_path(path: &str) -> std::path::PathBuf { + if path == "~" { + if let Some(home) = dirs_home() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs_home() { + return home.join(rest); + } + } + std::path::PathBuf::from(path) +} + +fn dirs_home() -> Option { + if let Ok(home) = std::env::var("HOME") { + return Some(std::path::PathBuf::from(home)); + } + None +} + +fn lua_machine_name() -> String { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(name) = sync_toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + { + return name.to_string(); + } + } + lua_hostname() +} + +fn lua_hostname() -> String { + // Try gethostname via libc + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + if !s.is_empty() { + return s.to_string(); + } + } + } + } + // Fall back to /etc/hostname + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let trimmed = h.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn lua_machine_tags() -> Vec { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(tags) = sync_toml + .get("machine") + .and_then(|m| m.get("tags")) + .and_then(|v| v.as_array()) + { + return tags + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + } + } + vec![] +} + +fn read_sync_toml() -> anyhow::Result { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| { + std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config")) + }) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let path = config_dir.join("bread").join("sync.toml"); + let raw = std::fs::read_to_string(path)?; + Ok(raw.parse::()?) +} + const BUILTIN_MONITORS: &str = r#" local M = bread.module({ name = "bread.monitors", version = "1.0.0" }) diff --git a/packaging/README.md b/packaging/README.md index 3f829f8..18256de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,5 +1,47 @@ -Packaging notes -================ +Packaging +========= -This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under -`packaging/arch/`. +This directory contains distribution packaging for Bread. + +``` +packaging/ +├── arch/ +│ └── PKGBUILD ← Arch Linux package build script +└── systemd/ + └── breadd.service ← systemd user service unit +``` + +## Arch Linux + +```bash +cd packaging/arch +makepkg -si +``` + +The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`. + +Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball. + +## systemd user service + +The service unit starts `breadd` as a user service after the graphical session is available. + +```bash +# Install and enable manually (if not using the PKGBUILD) +mkdir -p ~/.config/systemd/user +cp systemd/breadd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadd + +# Check status +systemctl --user status breadd +journalctl --user -u breadd -f +``` + +The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in: + +```ini +# ~/.config/systemd/user/breadd.service.d/debug.conf +[Service] +Environment=RUST_LOG=debug +``` diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 1873cd6..020e26c 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,9 +1,29 @@ Arch packaging ============== -This is a minimal PKGBUILD skeleton. +`PKGBUILD` builds and installs both `breadd` and `bread` from source. -Steps to use: -- Update `pkgver`, `source`, `sha256sums`, and `url`. -- Set the correct license and dependencies. -- Ensure the release tarball includes `packaging/systemd/breadd.service`. +## Local build + +```bash +makepkg -si +``` + +## Before publishing to AUR + +1. Tag a release on GitHub. +2. Update `pkgver` to match the tag. +3. Update `source` to the release tarball URL. +4. Run `updpkgsums` (or manually set `sha256sums`). +5. Update `url` if the repository has moved. +6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically. + +## Runtime dependencies + +| Package | Required | Notes | +|---------|----------|-------| +| `glibc` | yes | always | +| `udev` | yes | device events | +| `dbus` | optional | UPower battery events | +| `libnotify` | optional | `bread.notify()` (uses `notify-send`) | +| `git` | optional | `bread sync` push/pull | From d158fe21865f47e0da37fd74712ddf78604d93be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:25:35 +0000 Subject: [PATCH 4/4] docs: align daemon naming with package rename Agent-Logs-Url: https://github.com/Breadway/bread/sessions/1d380004-8d78-4a1f-9fbb-0c8a487b2e14 Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com> --- Documentation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation.md b/Documentation.md index c2ad50c..454f7a9 100644 --- a/Documentation.md +++ b/Documentation.md @@ -15,7 +15,7 @@ ## Overview -Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. +Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. - Daemon: long-running Rust process, source of truth for runtime state - Lua runtime: dedicated thread inside the daemon; automation logic lives here @@ -27,7 +27,7 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/breadd.toml` +- Daemon config: `~/.config/bread/bread.toml` - Lua entry point: `~/.config/bread/init.lua` - Lua modules: `~/.config/bread/modules/` @@ -465,7 +465,7 @@ Payload includes `online` and `interfaces`. ## Dictionary: IPC protocol -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON. Request: