revert
This commit is contained in:
parent
c65e50fe1c
commit
96e42bc370
18 changed files with 125 additions and 3432 deletions
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,4 +3,8 @@ Overview.md
|
||||||
DAEMON.md
|
DAEMON.md
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
<<<<<<< HEAD
|
||||||
.github
|
.github
|
||||||
|
=======
|
||||||
|
.github/
|
||||||
|
>>>>>>> parent of e561156 (Begin Implementing V2 features)
|
||||||
|
|
|
||||||
604
CLAUDE_SPEC.md
604
CLAUDE_SPEC.md
|
|
@ -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 <source> Install a module
|
|
||||||
bread modules remove <name> 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 <name> Update a specific module
|
|
||||||
bread modules info <name> Show full manifest details for a module
|
|
||||||
```
|
|
||||||
|
|
||||||
**`bread modules install <source>`**
|
|
||||||
|
|
||||||
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/<name>/`.
|
|
||||||
- Write `installed_at` into the manifest.
|
|
||||||
3. For local paths:
|
|
||||||
- Verify the path exists and contains `bread.module.toml`.
|
|
||||||
- Copy to `~/.config/bread/modules/<name>/`.
|
|
||||||
- Write `installed_at`.
|
|
||||||
4. Print `installed <name> v<version>` on success.
|
|
||||||
5. Tell the daemon to reload via IPC (`modules.reload`) after install.
|
|
||||||
|
|
||||||
**`bread modules remove <name>`**
|
|
||||||
|
|
||||||
1. Find `~/.config/bread/modules/<name>/`.
|
|
||||||
2. Ask for confirmation: `remove <name>? (y/n)`. Skip if `--yes` flag is passed.
|
|
||||||
3. Delete the directory.
|
|
||||||
4. Tell the daemon to reload via IPC.
|
|
||||||
5. Print `removed <name>`.
|
|
||||||
|
|
||||||
**`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 <name> v<old> → v<new>` or `<name> already up to date`.
|
|
||||||
|
|
||||||
**`bread modules info <name>`**
|
|
||||||
|
|
||||||
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 `<syspath>/idVendor` and `<syspath>/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option<String>`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```
|
|
||||||
<sync-repo>/
|
|
||||||
├── 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/<name>.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 <url>] Initialize sync for this machine
|
|
||||||
bread sync push [--message <msg>] 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 <url>]`**
|
|
||||||
|
|
||||||
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 <msg>]`**
|
|
||||||
|
|
||||||
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/` → `<repo>/bread/` (rsync-style: delete files in dest that don't exist in source)
|
|
||||||
- For each path in `delegates.include`: copy to `<repo>/configs/<basename>/`
|
|
||||||
- If `packages.enabled`: run package manager queries and write to `<repo>/packages/`
|
|
||||||
- Write `<repo>/machines/<name>.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: <machine-name> <timestamp>` 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 `<repo>/bread/` → `~/.config/bread/` (same rsync-style)
|
|
||||||
- For each path in `delegates.include` that exists in `<repo>/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/<branch>`.
|
|
||||||
|
|
||||||
**`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 <pkg>` |
|
|
||||||
| `pip` | `pip list --user --format=freeze` | `pip install --user -r <file>` |
|
|
||||||
| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g <pkg>` |
|
|
||||||
| `cargo` | `cargo install --list` | `cargo install <pkg>` |
|
|
||||||
|
|
||||||
For `cargo`, the snapshot format is one package per line: `<name> <version>`. 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 '<name>' 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/<name>/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/<name>.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 <name>` removes the module directory
|
|
||||||
- [ ] `bread modules remove <name>` with `--yes` skips confirmation
|
|
||||||
- [ ] `bread modules remove <nonexistent>` 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 <name>` 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/<basename>/`
|
|
||||||
- [ ] `bread sync push` writes package manifests to `packages/`
|
|
||||||
- [ ] `bread sync push` writes `machines/<name>.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<String>` and `product_id: Option<String>`
|
|
||||||
- [ ] `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/<anything>`
|
|
||||||
- [ ] 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
|
|
||||||
1090
Cargo.lock
generated
1090
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,8 +2,7 @@
|
||||||
members = [
|
members = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-cli",
|
"bread-cli"
|
||||||
"bread-sync"
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,12 +231,11 @@ bread.once("bread.system.startup", function(event)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Subscribe with a predicate filter
|
-- 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.filter("bread.device.connected", function(event)
|
||||||
bread.exec("xset r rate 200 40")
|
|
||||||
end, { filter = function(event)
|
|
||||||
return event.data.class == "keyboard"
|
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)
|
-- Emit a custom event (for cross-module communication)
|
||||||
bread.emit("mymodule.something", { key = "value" })
|
bread.emit("mymodule.something", { key = "value" })
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
bread-sync = { path = "../bread-sync" }
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
@ -17,9 +16,3 @@ anyhow.workspace = true
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
libc = "0.2"
|
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"
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
#[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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PackagesConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: true,
|
|
||||||
managers: default_managers(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_managers() -> Vec<String> {
|
|
||||||
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/<basename>/`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub include: Vec<String>,
|
|
||||||
/// Glob patterns to exclude when copying.
|
|
||||||
#[serde(default)]
|
|
||||||
pub exclude: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyncConfig {
|
|
||||||
/// Load from `~/.config/bread/sync.toml`, returning `Default` if not present.
|
|
||||||
pub fn load() -> Result<Self> {
|
|
||||||
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<bool> {
|
|
||||||
Ok(config_path()?.exists())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Path to `~/.config/bread/sync.toml`.
|
|
||||||
pub fn config_path() -> Result<PathBuf> {
|
|
||||||
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<PathBuf> {
|
|
||||||
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<PathBuf> {
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
|
|
||||||
Ok(config_dir.join("bread"))
|
|
||||||
}
|
|
||||||
|
|
@ -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 `<repo_root>/configs/<basename>/`.
|
|
||||||
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 `<repo_root>/configs/<basename>/` 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}");
|
|
||||||
}
|
|
||||||
|
|
@ -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<git2::Repository> {
|
|
||||||
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<git2::Repository> {
|
|
||||||
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<bool> {
|
|
||||||
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<git2::Oid> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<Vec<(char, String)>> {
|
|
||||||
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::<chrono::Utc>::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
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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 `<repo>/machines/<name>.toml`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MachineProfile {
|
|
||||||
pub name: String,
|
|
||||||
pub hostname: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub last_sync: String, // RFC 3339
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MachineProfile {
|
|
||||||
pub fn new(cfg: &SyncConfig) -> Result<Self> {
|
|
||||||
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 `<repo>/machines/<name>.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 `<repo>/machines/<name>.toml`.
|
|
||||||
pub fn load_from_repo(repo_root: &Path, name: &str) -> Result<Self> {
|
|
||||||
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 `<repo>/machines/`.
|
|
||||||
pub fn list_machines(repo_root: &Path) -> Vec<MachineProfile> {
|
|
||||||
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::<MachineProfile>(&raw).ok())
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the machine name from sync.toml, falling back to hostname.
|
|
||||||
pub fn machine_name(cfg: &SyncConfig) -> Result<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
// 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<Utc>) -> String {
|
|
||||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
/// Write package manifests to `<repo>/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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
let output = Command::new("cargo")
|
|
||||||
.args(["install", "--list"])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
String::from_utf8(output.stdout).ok()
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// Placeholder - tests will be added in step 15
|
|
||||||
|
|
@ -22,25 +22,20 @@ impl UdevAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
let payloads = match enumerate_with_udev(&self.subsystems) {
|
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
|
||||||
Ok(p) => p,
|
scan_devices(&self.subsystems).unwrap_or_default()
|
||||||
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 payload in payloads {
|
for device in devices {
|
||||||
tx.send(RawEvent {
|
tx.send(RawEvent {
|
||||||
source: AdapterSource::Udev,
|
source: AdapterSource::Udev,
|
||||||
kind: "udev.enumerate".to_string(),
|
kind: "udev.enumerate".to_string(),
|
||||||
payload,
|
payload: json!({
|
||||||
|
"action": "add",
|
||||||
|
"id": device.id,
|
||||||
|
"name": device.name,
|
||||||
|
"subsystem": device.subsystem,
|
||||||
|
}),
|
||||||
timestamp: now_unix_ms(),
|
timestamp: now_unix_ms(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -169,7 +164,7 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<serde_json::Value>> {
|
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
let mut enumerator = udev::Enumerator::new()?;
|
let mut enumerator = udev::Enumerator::new()?;
|
||||||
for subsystem in subsystems {
|
for subsystem in subsystems {
|
||||||
enumerator.match_subsystem(subsystem)?;
|
enumerator.match_subsystem(subsystem)?;
|
||||||
|
|
@ -189,38 +184,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<serde_json::Value>>
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let id = dev.syspath().to_string_lossy().to_string();
|
let id = dev.syspath().to_string_lossy().to_string();
|
||||||
|
|
||||||
out.push(json!({
|
out.push(ScannedDevice {
|
||||||
"action": "add",
|
id,
|
||||||
"id": id,
|
name,
|
||||||
"name": name,
|
subsystem,
|
||||||
"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)
|
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<String> {
|
|
||||||
dev.property_value(key)
|
|
||||||
.map(|v| v.to_string_lossy().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
|
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
|
||||||
RawEvent {
|
RawEvent {
|
||||||
source: AdapterSource::Udev,
|
source: AdapterSource::Udev,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue