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.
This commit is contained in:
parent
251c586b6f
commit
364a35142e
25 changed files with 3930 additions and 92 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,10 +1,8 @@
|
||||||
target/
|
target/
|
||||||
Overview.md
|
Overview.md
|
||||||
DAEMON.md
|
DAEMON.md
|
||||||
|
LUA_RUNTIME.md
|
||||||
|
CLAUDE_SPEC.md
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
<<<<<<< HEAD
|
|
||||||
.github
|
.github
|
||||||
=======
|
|
||||||
.github/
|
|
||||||
>>>>>>> parent of e561156 (Begin Implementing V2 features)
|
|
||||||
|
|
|
||||||
1103
Cargo.lock
generated
1103
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,8 @@
|
||||||
members = [
|
members = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-cli"
|
"bread-cli",
|
||||||
|
"bread-sync",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
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"
|
||||||
|
|
|
||||||
314
README.md
314
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.
|
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:
|
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
|
||||||
|
|
||||||
```lua
|
```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.profile.activate("desk")
|
||||||
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
|
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
|
||||||
|
bread.notify("Dock connected", { urgency = "low" })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
bread.on("bread.device.dock.disconnected", function()
|
bread.on("bread.device.dock.disconnected", function(event)
|
||||||
bread.profile.activate("default")
|
bread.profile.activate("default")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
return M
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -40,6 +45,7 @@ end)
|
||||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||||
|
bread-sync/ Sync engine — snapshot and restore system state via a Git remote
|
||||||
packaging/ Arch PKGBUILD and systemd user service
|
packaging/ Arch PKGBUILD and systemd user service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system
|
||||||
bash scripts/install.sh
|
bash scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or do it step by step:
|
Or step by step:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
@ -141,19 +147,16 @@ default_urgency = "normal"
|
||||||
notify_send_path = "notify-send"
|
notify_send_path = "notify-send"
|
||||||
|
|
||||||
[modules]
|
[modules]
|
||||||
builtin = true # load built-in modules (monitors, devices, etc.)
|
builtin = true # load built-in modules (monitors, devices, workspaces, binds)
|
||||||
disable = [] # list of built-in module names to disable
|
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
|
```lua
|
||||||
-- ~/.config/bread/init.lua
|
-- ~/.config/bread/init.lua
|
||||||
|
|
||||||
require("modules.devices")
|
bread.on("bread.system.startup", function(event)
|
||||||
require("modules.workspaces")
|
|
||||||
|
|
||||||
bread.on("bread.system.startup", function()
|
|
||||||
bread.profile.activate("default")
|
bread.profile.activate("default")
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
@ -165,19 +168,144 @@ end)
|
||||||
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||||
|
|
||||||
```bash
|
```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 # Hot-reload all Lua modules
|
||||||
bread reload --watch # Watch config dir and reload on changes
|
bread reload --watch # Watch config dir and reload on changes
|
||||||
|
|
||||||
|
# State and events
|
||||||
bread state # Dump full runtime state as JSON
|
bread state # Dump full runtime state as JSON
|
||||||
bread events # Stream live normalized events
|
bread events # Stream live normalized events
|
||||||
bread events --filter bread.device.* # Stream filtered events
|
bread events --filter bread.device.* # Stream filtered events
|
||||||
bread events --since 60 # Replay events from the last 60 seconds
|
bread events --since 60 # Replay events from the last 60 seconds
|
||||||
bread modules # List loaded modules and status
|
bread emit <event> # Manually fire an event (for testing)
|
||||||
|
|
||||||
|
# Profiles
|
||||||
bread profile-list # List defined profiles
|
bread profile-list # List defined profiles
|
||||||
bread profile-activate <name> # Activate a named profile
|
bread profile-activate <name> # Activate a named profile
|
||||||
bread emit <event> --data '{}' # Manually fire an event (for testing)
|
|
||||||
bread ping # Check daemon connectivity
|
# Modules
|
||||||
bread health # Daemon version, uptime, PID
|
bread modules list # List installed modules and daemon status
|
||||||
bread doctor # Diagnose daemon and module health
|
bread modules install github:user/repo # Install from GitHub
|
||||||
|
bread modules install /local/path # Install from a local directory
|
||||||
|
bread modules remove <name> # Remove an installed module
|
||||||
|
bread modules update [name] # Re-install one or all GitHub-sourced modules
|
||||||
|
bread modules info <name> # 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.<subsystem>.<noun>.<verb>`.
|
||||||
| `bread.network.connected` | Network interface came online |
|
| `bread.network.connected` | Network interface came online |
|
||||||
| `bread.network.disconnected` | Network interface went offline |
|
| `bread.network.disconnected` | Network interface went offline |
|
||||||
| `bread.profile.activated` | Profile switched |
|
| `bread.profile.activated` | Profile switched |
|
||||||
|
| `bread.notify.sent` | Desktop notification dispatched |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lua API
|
## 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
|
### Events
|
||||||
|
|
||||||
```lua
|
```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)
|
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)
|
end)
|
||||||
|
|
||||||
-- Unsubscribe by ID
|
-- Unsubscribe by ID
|
||||||
bread.off(id)
|
bread.off(id)
|
||||||
|
|
||||||
-- Subscribe once, then auto-unsubscribe
|
-- Subscribe once, auto-unsubscribe after first delivery
|
||||||
bread.once("bread.system.startup", function(event)
|
bread.once("bread.system.startup", function(event)
|
||||||
-- runs exactly once
|
bread.profile.activate("default")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Subscribe with a predicate filter
|
-- Subscribe with a filter predicate
|
||||||
bread.filter("bread.device.connected", function(event)
|
bread.filter("bread.device.connected", function(event)
|
||||||
return event.data.class == "keyboard"
|
return event.data.class == "keyboard"
|
||||||
end, function(event)
|
end, function(event)
|
||||||
|
|
@ -241,18 +389,33 @@ end)
|
||||||
bread.emit("mymodule.something", { key = "value" })
|
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
|
### State
|
||||||
|
|
||||||
```lua
|
```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 monitors = bread.state.get("monitors")
|
||||||
local workspace = bread.state.get("active_workspace")
|
local online = bread.state.get("network.online")
|
||||||
local power = bread.state.get("power")
|
|
||||||
local devices = bread.state.get("devices")
|
|
||||||
|
|
||||||
-- Watch a state key and fire on changes
|
-- Typed shorthands
|
||||||
bread.state.watch("active_workspace", function(new, old)
|
local monitors = bread.state.monitors()
|
||||||
print("workspace changed from " .. tostring(old) .. " to " .. tostring(new))
|
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)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -266,39 +429,99 @@ bread.profile.activate("default")
|
||||||
### Execution and notifications
|
### Execution and notifications
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
-- Fire-and-forget: returns immediately, process runs in background
|
-- Fire-and-forget shell command
|
||||||
bread.exec("kitty")
|
bread.exec("kitty")
|
||||||
|
|
||||||
-- Desktop notification
|
-- Desktop notification (uses notify-send)
|
||||||
bread.notify("Dock connected", { urgency = "normal", timeout = 3000 })
|
bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" })
|
||||||
|
bread.notify("Simple message") -- title defaults to "bread"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Timers
|
### Timers
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
-- Run once after a delay (ms)
|
-- Run once after a delay (ms)
|
||||||
bread.after(500, function()
|
local id = bread.after(500, function()
|
||||||
bread.exec("some-delayed-command")
|
bread.exec("some-delayed-command")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Run on a repeating interval (ms); returns a timer ID
|
-- Run on a repeating interval (ms)
|
||||||
local id = bread.every(60000, function()
|
local id = bread.every(60000, function()
|
||||||
bread.log("tick")
|
bread.log("tick")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- Cancel either kind
|
||||||
bread.cancel(id)
|
bread.cancel(id)
|
||||||
|
|
||||||
-- Debounce a rapidly-firing handler
|
-- Debounce a rapidly-firing handler
|
||||||
local fn = bread.debounce(200, function(event)
|
local fn = bread.debounce(200, function(event)
|
||||||
reconfigure_monitors()
|
reconfigure_monitors()
|
||||||
end)
|
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
|
### Logging
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
bread.log("Module loaded")
|
bread.log("Module loaded") -- info level
|
||||||
bread.warn("Unexpected state")
|
bread.warn("Unexpected state") -- warn level
|
||||||
bread.error("Something failed")
|
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 } ] }
|
{ "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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,24 @@ edition = "2021"
|
||||||
name = "bread"
|
name = "bread"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "bread_cli"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
|
bread-sync = { path = "../bread-sync" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
dirs.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"
|
||||||
|
toml = "0.8"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
flate2 = "1.0"
|
||||||
|
tar = "0.4"
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
2
bread-cli/src/lib.rs
Normal file
2
bread-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// Module management (install, remove, list, update, info).
|
||||||
|
pub mod modules_mgmt;
|
||||||
|
|
@ -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 clap::{Parser, Subcommand};
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io;
|
use std::io::{self, Write as IoWrite};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
@ -47,8 +54,16 @@ enum Commands {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
since: Option<u64>,
|
since: Option<u64>,
|
||||||
},
|
},
|
||||||
/// List loaded modules and status
|
/// Manage installed Lua modules
|
||||||
Modules,
|
Modules {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: ModulesCommand,
|
||||||
|
},
|
||||||
|
/// Manage sync (snapshot and restore system state)
|
||||||
|
Sync {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: SyncCommand,
|
||||||
|
},
|
||||||
/// List available profiles
|
/// List available profiles
|
||||||
ProfileList,
|
ProfileList,
|
||||||
/// Activate a profile
|
/// Activate a profile
|
||||||
|
|
@ -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<String>,
|
||||||
|
},
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
/// Snapshot and push current state
|
||||||
|
Push {
|
||||||
|
/// Custom commit message
|
||||||
|
#[arg(long)]
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
/// Pull and apply latest state
|
||||||
|
Pull {
|
||||||
|
/// Also install packages from manifest
|
||||||
|
#[arg(long)]
|
||||||
|
install_packages: bool,
|
||||||
|
},
|
||||||
|
/// Show what has changed since last push
|
||||||
|
Status,
|
||||||
|
/// Show file-level diff vs last commit (or vs remote with --remote)
|
||||||
|
Diff {
|
||||||
|
#[arg(long)]
|
||||||
|
remote: bool,
|
||||||
|
},
|
||||||
|
/// List known machines from sync repo
|
||||||
|
Machines,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let socket = daemon_socket_path();
|
let socket = daemon_socket_path();
|
||||||
|
|
||||||
match &cli.command {
|
match cli.command {
|
||||||
Commands::Reload { watch } => {
|
Commands::Reload { watch } => {
|
||||||
if *watch {
|
if watch {
|
||||||
watch_reload(&socket).await?;
|
watch_reload(&socket).await?;
|
||||||
} else {
|
} else {
|
||||||
let response = send_request(&socket, "modules.reload", json!({})).await?;
|
let response = send_request(&socket, "modules.reload", json!({})).await?;
|
||||||
|
|
@ -86,19 +157,14 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::State { path, json } => {
|
Commands::State { path, json } => {
|
||||||
if *json {
|
let response = if let Some(ref path) = path {
|
||||||
let response = if let Some(path) = path {
|
|
||||||
send_request(&socket, "state.get", json!({ "key": path })).await?
|
send_request(&socket, "state.get", json!({ "key": path })).await?
|
||||||
} else {
|
} else {
|
||||||
send_request(&socket, "state.dump", json!({})).await?
|
send_request(&socket, "state.dump", json!({})).await?
|
||||||
};
|
};
|
||||||
|
if json {
|
||||||
print_json(&response)?;
|
print_json(&response)?;
|
||||||
} else {
|
} 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);
|
print_state_formatted(path.as_deref(), &response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,22 +174,25 @@ async fn main() -> Result<()> {
|
||||||
fields,
|
fields,
|
||||||
since,
|
since,
|
||||||
} => {
|
} => {
|
||||||
stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?;
|
stream_events(&socket, filter, json, fields, since).await?;
|
||||||
}
|
}
|
||||||
Commands::Modules => {
|
Commands::Modules { subcommand } => {
|
||||||
let response = send_request(&socket, "modules.list", json!({})).await?;
|
handle_modules_cmd(subcommand, &socket).await?;
|
||||||
print_json(&response)?;
|
}
|
||||||
|
Commands::Sync { subcommand } => {
|
||||||
|
handle_sync_cmd(subcommand, &socket).await?;
|
||||||
}
|
}
|
||||||
Commands::ProfileList => {
|
Commands::ProfileList => {
|
||||||
let response = send_request(&socket, "profile.list", json!({})).await?;
|
let response = send_request(&socket, "profile.list", json!({})).await?;
|
||||||
print_json(&response)?;
|
print_json(&response)?;
|
||||||
}
|
}
|
||||||
Commands::ProfileActivate { name } => {
|
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)?;
|
print_json(&response)?;
|
||||||
}
|
}
|
||||||
Commands::Emit { event, data } => {
|
Commands::Emit { event, data } => {
|
||||||
let parsed = serde_json::from_str::<Value>(data).unwrap_or_else(|_| json!({}));
|
let parsed = serde_json::from_str::<Value>(&data).unwrap_or_else(|_| json!({}));
|
||||||
let response = send_request(
|
let response = send_request(
|
||||||
&socket,
|
&socket,
|
||||||
"emit",
|
"emit",
|
||||||
|
|
@ -144,7 +213,7 @@ async fn main() -> Result<()> {
|
||||||
print_json(&response)?;
|
print_json(&response)?;
|
||||||
}
|
}
|
||||||
Commands::Doctor { json } => {
|
Commands::Doctor { json } => {
|
||||||
if *json {
|
if json {
|
||||||
let response = send_request(&socket, "health", json!({})).await?;
|
let response = send_request(&socket, "health", json!({})).await?;
|
||||||
print_json(&response)?;
|
print_json(&response)?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -156,6 +225,580 @@ async fn main() -> Result<()> {
|
||||||
Ok(())
|
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::<std::collections::HashMap<_, _>>(),
|
||||||
|
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<modules_mgmt::ModuleManifest> {
|
||||||
|
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<modules_mgmt::ModuleManifest> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("bread-cli/0.1")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let ref_to_use = match git_ref {
|
||||||
|
Some(r) => r.to_string(),
|
||||||
|
None => {
|
||||||
|
let url = format!("https://api.github.com/repos/{user}/{repo}");
|
||||||
|
let resp: Value = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to reach GitHub API")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("failed to parse GitHub API response")?;
|
||||||
|
resp.get("default_branch")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("main")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tarball_url =
|
||||||
|
format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}");
|
||||||
|
let bytes = client
|
||||||
|
.get(&tarball_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to download module archive")?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("failed to read module archive")?;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir()?;
|
||||||
|
let mut archive =
|
||||||
|
tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
|
||||||
|
archive.unpack(tmp.path())?;
|
||||||
|
|
||||||
|
// GitHub extracts to a single subdirectory (e.g. "user-repo-sha/")
|
||||||
|
let root = std::fs::read_dir(tmp.path())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.find(|e| e.path().is_dir())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?;
|
||||||
|
|
||||||
|
modules_mgmt::install_from_local(&root, source_str, mods_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable.
|
||||||
|
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<String>) -> Result<()> {
|
||||||
|
let sync_toml = cfg_dir.join("sync.toml");
|
||||||
|
if sync_toml.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"bread: sync already initialized. Edit {} to reconfigure.",
|
||||||
|
sync_toml.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_url = match remote {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
print!("Sync remote URL (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<String> = tags_line
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let config = SyncConfig {
|
||||||
|
remote: bread_sync::config::RemoteConfig {
|
||||||
|
url: remote_url.clone(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
},
|
||||||
|
machine: bread_sync::config::MachineConfig {
|
||||||
|
name: machine_name.clone(),
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
packages: bread_sync::config::PackagesConfig::default(),
|
||||||
|
delegates: bread_sync::config::DelegatesConfig::default(),
|
||||||
|
};
|
||||||
|
config.save(cfg_dir)?;
|
||||||
|
|
||||||
|
// 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<String>) -> 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<SyncConfig> {
|
||||||
|
match SyncConfig::load(cfg_dir) {
|
||||||
|
Ok(c) => Ok(c),
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("bread: sync not initialized. Run: bread sync init");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> {
|
||||||
|
for manager in managers {
|
||||||
|
let file = packages_dir.join(format!("{manager}.txt"));
|
||||||
|
if !file.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&file)?;
|
||||||
|
match manager.as_str() {
|
||||||
|
"pacman" => {
|
||||||
|
let pkgs = packages::parse_pacman(&content);
|
||||||
|
if pkgs.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut cmd = std::process::Command::new("sudo");
|
||||||
|
cmd.args(["pacman", "-S", "--needed"]).args(&pkgs);
|
||||||
|
let _ = cmd.status();
|
||||||
|
}
|
||||||
|
"pip" => {
|
||||||
|
let mut cmd = std::process::Command::new("pip");
|
||||||
|
cmd.args(["install", "--user", "-r"])
|
||||||
|
.arg(file.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 {
|
fn daemon_socket_path() -> PathBuf {
|
||||||
if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") {
|
if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") {
|
||||||
return Path::new(&runtime).join("bread").join("breadd.sock");
|
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<Value> {
|
async fn send_request(socket: &Path, method: &str, params: Value) -> Result<Value> {
|
||||||
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 (read_half, mut write_half) = stream.into_split();
|
||||||
let request = json!({
|
let request = json!({
|
||||||
"id": "1",
|
"id": "1",
|
||||||
|
|
@ -195,7 +849,9 @@ async fn stream_events(
|
||||||
since: Option<u64>,
|
since: Option<u64>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(seconds) = since {
|
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() {
|
if let Some(list) = replay.as_array() {
|
||||||
for item in list {
|
for item in list {
|
||||||
if raw_json {
|
if raw_json {
|
||||||
|
|
@ -303,9 +959,7 @@ fn format_timestamp(ms: u64) -> String {
|
||||||
let mut tm: libc::tm = std::mem::zeroed();
|
let mut tm: libc::tm = std::mem::zeroed();
|
||||||
let t = secs as libc::time_t;
|
let t = secs as libc::time_t;
|
||||||
libc::localtime_r(&t, &mut tm);
|
libc::localtime_r(&t, &mut tm);
|
||||||
tm.tm_hour as u64 * 3600
|
tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64
|
||||||
+ tm.tm_min as u64 * 60
|
|
||||||
+ tm.tm_sec as u64
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let h = (local_secs / 3600) % 24;
|
let h = (local_secs / 3600) % 24;
|
||||||
|
|
@ -346,8 +1000,6 @@ async fn watch_reload(socket: &Path) -> Result<()> {
|
||||||
continue;
|
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;
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
while rx.try_recv().is_ok() {}
|
while rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
|
@ -384,10 +1036,20 @@ fn render_doctor(health: &Value) {
|
||||||
println!("bread doctor");
|
println!("bread doctor");
|
||||||
let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
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 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 version = health
|
||||||
let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0);
|
.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("?");
|
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!(" version {version}");
|
||||||
println!(" uptime {}s", uptime_ms / 1000);
|
println!(" uptime {}s", uptime_ms / 1000);
|
||||||
println!(" socket {socket}");
|
println!(" socket {socket}");
|
||||||
|
|
|
||||||
175
bread-cli/src/modules_mgmt.rs
Normal file
175
bread-cli/src/modules_mgmt.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
},
|
||||||
|
LocalPath(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a source string into an `InstallSource`.
|
||||||
|
pub fn parse_source(source: &str) -> Result<InstallSource> {
|
||||||
|
if let Some(rest) = source.strip_prefix("github:") {
|
||||||
|
let (repo_part, ref_part) = rest
|
||||||
|
.split_once('@')
|
||||||
|
.map(|(r, v)| (r, Some(v.to_string())))
|
||||||
|
.unwrap_or((rest, None));
|
||||||
|
let (user, repo) = repo_part.split_once('/').ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'",
|
||||||
|
source
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(InstallSource::GitHub {
|
||||||
|
user: user.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
git_ref: ref_part,
|
||||||
|
})
|
||||||
|
} else if source.starts_with('/')
|
||||||
|
|| source.starts_with("./")
|
||||||
|
|| source.starts_with("../")
|
||||||
|
|| source.starts_with('~')
|
||||||
|
{
|
||||||
|
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<ModuleManifest> {
|
||||||
|
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<Vec<ModuleManifest>> {
|
||||||
|
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<ModuleManifest> {
|
||||||
|
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<ModuleManifest> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
133
bread-cli/tests/modules.rs
Normal file
133
bread-cli/tests/modules.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
18
bread-sync/Cargo.toml
Normal file
18
bread-sync/Cargo.toml
Normal file
|
|
@ -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
|
||||||
88
bread-sync/README.md
Normal file
88
bread-sync/README.md
Normal file
|
|
@ -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>
|
||||||
|
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>
|
||||||
|
SyncRepo::open(path: &Path) -> Result<SyncRepo>
|
||||||
|
SyncRepo::clone_from(url: &str, path: &Path) -> Result<SyncRepo>
|
||||||
|
SyncRepo::open_or_clone(url: &str, path: &Path) -> Result<SyncRepo>
|
||||||
|
SyncRepo::commit(&self, message: &str) -> Result<Option<git2::Oid>> // 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<bool>
|
||||||
|
SyncRepo::local_changes(&self) -> Result<Vec<(char, String)>>
|
||||||
|
SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result<Vec<(char, String)>>
|
||||||
|
SyncRepo::working_diff(&self) -> Result<String>
|
||||||
|
SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result<String>
|
||||||
|
SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()>
|
||||||
|
SyncRepo::last_commit_time(&self) -> Option<DateTime<Local>>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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<String>) -> MachineProfile
|
||||||
|
MachineProfile::write(&self, machines_dir: &Path) -> Result<()>
|
||||||
|
MachineProfile::read(machines_dir: &Path, name: &str) -> Result<MachineProfile>
|
||||||
|
MachineProfile::list(machines_dir: &Path) -> Result<Vec<MachineProfile>>
|
||||||
|
hostname() -> String
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
snapshot(manager: &str, dest: &Path) -> Result<bool> // false = manager not found (non-fatal)
|
||||||
|
parse_pacman(content: &str) -> Vec<String>
|
||||||
|
parse_pip(content: &str) -> Vec<String>
|
||||||
|
parse_npm(content: &str) -> Vec<String>
|
||||||
|
parse_cargo(content: &str) -> Vec<String>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.local/share/bread/sync-repo/
|
||||||
|
├── bread/ ← snapshot of ~/.config/bread/
|
||||||
|
├── configs/
|
||||||
|
│ └── <basename>/ ← delegate paths
|
||||||
|
├── machines/
|
||||||
|
│ └── <name>.toml ← per-machine profiles
|
||||||
|
└── packages/
|
||||||
|
├── pacman.txt
|
||||||
|
├── pip.txt
|
||||||
|
├── npm.txt
|
||||||
|
└── cargo.txt
|
||||||
|
```
|
||||||
135
bread-sync/src/config.rs
Normal file
135
bread-sync/src/config.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PackagesConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub managers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncConfig {
|
||||||
|
/// Load sync config from the given bread config directory.
|
||||||
|
pub fn load(config_dir: &Path) -> Result<Self> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
109
bread-sync/src/delegates.rs
Normal file
109
bread-sync/src/delegates.rs
Normal file
|
|
@ -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<Pattern> = 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()
|
||||||
|
}
|
||||||
366
bread-sync/src/git.rs
Normal file
366
bread-sync/src/git.rs
Normal file
|
|
@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Option<git2::Oid>> {
|
||||||
|
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<bool> {
|
||||||
|
Ok(self.local_changes()?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns list of (status_char, path) for working-tree changes vs HEAD.
|
||||||
|
pub fn local_changes(&self) -> Result<Vec<(char, String)>> {
|
||||||
|
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<Vec<(char, String)>> {
|
||||||
|
// 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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<chrono::DateTime<chrono::Local>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
9
bread-sync/src/lib.rs
Normal file
9
bread-sync/src/lib.rs
Normal file
|
|
@ -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;
|
||||||
79
bread-sync/src/machine.rs
Normal file
79
bread-sync/src/machine.rs
Normal file
|
|
@ -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/<name>.toml` in the sync repo.
|
||||||
|
#[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 {
|
||||||
|
/// Create a new profile for this machine.
|
||||||
|
pub fn new(name: String, tags: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
hostname: hostname(),
|
||||||
|
name,
|
||||||
|
tags,
|
||||||
|
last_sync: Utc::now().to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write this profile to `<machines_dir>/<name>.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 `<machines_dir>/<name>.toml`.
|
||||||
|
pub fn read(machines_dir: &Path, name: &str) -> Result<Self> {
|
||||||
|
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<Vec<Self>> {
|
||||||
|
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::<Self>(&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())
|
||||||
|
}
|
||||||
144
bread-sync/src/packages.rs
Normal file
144
bread-sync/src/packages.rs
Normal file
|
|
@ -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<bool> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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<Option<String>> {
|
||||||
|
// 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<Option<String>> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
257
bread-sync/tests/sync.rs
Normal file
257
bread-sync/tests/sync.rs
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,8 @@ struct ScannedDevice {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
subsystem: String,
|
subsystem: String,
|
||||||
|
vendor_id: Option<String>,
|
||||||
|
product_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
|
@ -148,6 +150,8 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
|
||||||
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
|
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
|
||||||
"id_vendor": prop_str(&event, "ID_VENDOR"),
|
"id_vendor": prop_str(&event, "ID_VENDOR"),
|
||||||
"id_model": prop_str(&event, "ID_MODEL"),
|
"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(),
|
timestamp: now_unix_ms(),
|
||||||
};
|
};
|
||||||
|
|
@ -183,11 +187,19 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||||
.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();
|
||||||
|
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 {
|
out.push(ScannedDevice {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
subsystem,
|
subsystem,
|
||||||
|
vendor_id,
|
||||||
|
product_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
|
||||||
"id": dev.id,
|
"id": dev.id,
|
||||||
"name": dev.name,
|
"name": dev.name,
|
||||||
"subsystem": dev.subsystem,
|
"subsystem": dev.subsystem,
|
||||||
|
"vendor_id": dev.vendor_id,
|
||||||
|
"product_id": dev.product_id,
|
||||||
}),
|
}),
|
||||||
timestamp: now_unix_ms(),
|
timestamp: now_unix_ms(),
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
id: format!("drm:{name}"),
|
id: format!("drm:{name}"),
|
||||||
name,
|
name,
|
||||||
subsystem: "drm".to_string(),
|
subsystem: "drm".to_string(),
|
||||||
|
vendor_id: None,
|
||||||
|
product_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
id: format!("input:{name}"),
|
id: format!("input:{name}"),
|
||||||
name,
|
name,
|
||||||
subsystem: "input".to_string(),
|
subsystem: "input".to_string(),
|
||||||
|
vendor_id: None,
|
||||||
|
product_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
id: format!("power_supply:{name}"),
|
id: format!("power_supply:{name}"),
|
||||||
name,
|
name,
|
||||||
subsystem: "power_supply".to_string(),
|
subsystem: "power_supply".to_string(),
|
||||||
|
vendor_id: None,
|
||||||
|
product_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
|
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 {
|
out.push(ScannedDevice {
|
||||||
id: format!("usb:{name}"),
|
id: format!("usb:{name}"),
|
||||||
name,
|
name,
|
||||||
subsystem: "usb".to_string(),
|
subsystem: "usb".to_string(),
|
||||||
|
vendor_id,
|
||||||
|
product_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ pub enum StateCommand {
|
||||||
id: SubscriptionId,
|
id: SubscriptionId,
|
||||||
},
|
},
|
||||||
ClearSubscriptions,
|
ClearSubscriptions,
|
||||||
|
ClearModules,
|
||||||
SetModuleStatus {
|
SetModuleStatus {
|
||||||
name: String,
|
name: String,
|
||||||
status: ModuleLoadState,
|
status: ModuleLoadState,
|
||||||
|
|
@ -112,6 +113,10 @@ impl StateHandle {
|
||||||
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
|
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_modules(&self) {
|
||||||
|
let _ = self.command_tx.send(StateCommand::ClearModules);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_module_status(
|
pub fn set_module_status(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -236,6 +241,9 @@ async fn handle_command(
|
||||||
watches.clear();
|
watches.clear();
|
||||||
subscription_count.store(0, Ordering::Relaxed);
|
subscription_count.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
StateCommand::ClearModules => {
|
||||||
|
state.write().await.modules.clear();
|
||||||
|
}
|
||||||
StateCommand::SetModuleStatus {
|
StateCommand::SetModuleStatus {
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
|
|
@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string(),
|
.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 {
|
} else {
|
||||||
state.devices.connected.retain(|d| d.id != id);
|
state.devices.connected.retain(|d| d.id != id);
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ pub struct Device {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub class: DeviceClass,
|
pub class: DeviceClass,
|
||||||
pub subsystem: String,
|
pub subsystem: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub vendor_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub product_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,39 @@ impl Server {
|
||||||
"recent_errors": recent_errors,
|
"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::<toml::Value>().ok())
|
||||||
|
{
|
||||||
|
Some(toml) => {
|
||||||
|
let machine = toml
|
||||||
|
.get("machine")
|
||||||
|
.and_then(|m| m.get("name"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let remote = toml
|
||||||
|
.get("remote")
|
||||||
|
.and_then(|r| r.get("url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
Ok(json!({
|
||||||
|
"initialized": true,
|
||||||
|
"machine": machine,
|
||||||
|
"remote": remote,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(json!({ "initialized": false })),
|
||||||
|
}
|
||||||
|
}
|
||||||
"events.replay" => {
|
"events.replay" => {
|
||||||
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
|
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);
|
let cutoff = now_unix_ms().saturating_sub(since_ms);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use bread_shared::{AdapterSource, BreadEvent};
|
use bread_shared::{AdapterSource, BreadEvent};
|
||||||
|
use libc;
|
||||||
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
|
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
|
@ -250,6 +251,7 @@ impl LuaEngine {
|
||||||
self.run_on_unload();
|
self.run_on_unload();
|
||||||
self.cancel_all_timers();
|
self.cancel_all_timers();
|
||||||
self.state_handle.clear_subscriptions();
|
self.state_handle.clear_subscriptions();
|
||||||
|
self.state_handle.clear_modules();
|
||||||
self.lua = Lua::new();
|
self.lua = Lua::new();
|
||||||
self.handlers
|
self.handlers
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -837,6 +839,66 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
bread.set("module", module_fn)?;
|
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)?;
|
globals.set("bread", bread)?;
|
||||||
self.install_require_loader()?;
|
self.install_require_loader()?;
|
||||||
self.install_wait_helper()?;
|
self.install_wait_helper()?;
|
||||||
|
|
@ -927,7 +989,7 @@ impl LuaEngine {
|
||||||
|
|
||||||
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
|
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
|
||||||
self.set_current_module(Some(decl.name.clone()));
|
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)
|
self.load_lua_source(source, &decl.name)
|
||||||
} else {
|
} else {
|
||||||
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
|
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
|
||||||
|
|
@ -1296,16 +1358,31 @@ impl LuaEngine {
|
||||||
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
|
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()?;
|
let bread = lua.create_table()?;
|
||||||
bread.set("module", module_fn)?;
|
bread.set("module", module_fn)?;
|
||||||
lua.globals().set("bread", bread)?;
|
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 src = fs::read_to_string(path)?;
|
||||||
let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec();
|
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 {
|
if let Err(err) = result {
|
||||||
match err {
|
if !err.to_string().contains(MODULE_DECL_ABORT) {
|
||||||
LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {}
|
return Err(anyhow!(err.to_string()));
|
||||||
other => return Err(anyhow!(other.to_string())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, 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<std::path::PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
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<toml::Value> {
|
||||||
|
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::<toml::Value>()?)
|
||||||
|
}
|
||||||
|
|
||||||
const BUILTIN_MONITORS: &str = r#"
|
const BUILTIN_MONITORS: &str = r#"
|
||||||
local M = bread.module({ name = "bread.monitors", version = "1.0.0" })
|
local M = bread.module({ name = "bread.monitors", version = "1.0.0" })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,47 @@
|
||||||
Packaging notes
|
Packaging
|
||||||
================
|
=========
|
||||||
|
|
||||||
This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under
|
This directory contains distribution packaging for Bread.
|
||||||
`packaging/arch/`.
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,29 @@
|
||||||
Arch packaging
|
Arch packaging
|
||||||
==============
|
==============
|
||||||
|
|
||||||
This is a minimal PKGBUILD skeleton.
|
`PKGBUILD` builds and installs both `breadd` and `bread` from source.
|
||||||
|
|
||||||
Steps to use:
|
## Local build
|
||||||
- Update `pkgver`, `source`, `sha256sums`, and `url`.
|
|
||||||
- Set the correct license and dependencies.
|
```bash
|
||||||
- Ensure the release tarball includes `packaging/systemd/breadd.service`.
|
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 |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue