Compare commits

..

No commits in common. "85a1a867ce8c4fb921e3faeb8bf84c5e799fe083" and "74a3dc5cfa6edbde87cb401cfbfa51e70e531853" have entirely different histories.

15 changed files with 161 additions and 999 deletions

269
Cargo.lock generated
View file

@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bakery" name = "bakery"
version = "0.2.3" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -91,7 +91,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile",
"toml 0.8.23", "toml 0.8.23",
"ureq", "ureq",
] ]
@ -119,7 +118,7 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.2.3" version = "0.1.0"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -323,22 +322,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -365,12 +348,6 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -520,19 +497,6 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@ -723,15 +687,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@ -856,12 +811,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@ -890,9 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@ -919,12 +866,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -940,12 +881,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -1061,16 +996,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -1098,19 +1023,13 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@ -1123,7 +1042,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom 0.2.17", "getrandom",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -1138,19 +1057,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@ -1353,19 +1259,6 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -1500,12 +1393,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -1572,24 +1459,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.122"
@ -1635,40 +1504,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@ -1912,100 +1747,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View file

@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.2.3" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway <rileyhorsham@gmail.com>"] authors = ["Breadway <rileyhorsham@gmail.com>"]

View file

@ -12,11 +12,11 @@ bakery install breadbar
| Package | Description | | Package | Description |
|---------|-------------| |---------|-------------|
| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events | | `bread` | Reactive automation daemon (`breadd`) + CLI |
| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland | | `breadbar` | Status bar and notification daemon |
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) | | `breadbox` | Cloud sync daemon (`breadbox-sync`) + file browser |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | | `breadcrumbs` | Network information CLI |
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | | `breadpad` | Scratchpad / quick-note app |
## Installing bakery ## Installing bakery
@ -49,18 +49,13 @@ bakery remove <pkg> # remove a package (data files are never deleted)
## System dependencies by product ## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block. | Package | Arch packages |
|---------|--------------|
| Package | Required | Optional | | `bread` | `libudev` `dbus` |
|---------|----------|---------| | `breadbar` | `gtk4` `gtk4-layer-shell` `dbus` `iw` |
| `bakery` | _(statically linked, none)_ | — | | `breadbox` | `gtk4` `librsvg` `dbus` |
| `bread` | `systemd-libs` `openssl` `zlib` | `bluez` `hyprland` | | `breadcrumbs` | `networkmanager` |
| `breadbar` | `gtk4` `gtk4-layer-shell` `iw` `libpulse` | `hyprland` | | `breadpad` | `gtk4` `dbus` |
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` | `hyprland` |
| `breadcrumbs` | `networkmanager` | `tailscale` `sudo` `xdg-utils` |
| `breadpad` | `gtk4` `gtk4-layer-shell` | `rocm-hip-runtime` `ollama` `hyprland` |
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` to check whether any are already present.
## Theming ## Theming
@ -95,22 +90,6 @@ and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back `bakery` always tries `dl.breadway.dev` first and transparently falls back
to the GitHub Release URL recorded in the manifest. to the GitHub Release URL recorded in the manifest.
### Release artifact contract
Each product's `release.yml` **must** upload the following files alongside
the binary to `dl.breadway.dev/<name>/<version>/`:
| File | Purpose |
|------|---------|
| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` |
| `<binary>-x86_64.sha256` | Checksum verified by `bakery install` and `get.sh` |
| `*.service` | systemd unit files installed by `bakery install` |
| `*.example.toml` / `config.example.toml` | Example configs copied on first install |
`gen-index.sh` **fails loudly** if `bakery.toml` is missing — this is by
design to catch omissions in the release workflow before they silently
produce empty metadata in production.
## License ## License
MIT MIT

View file

@ -2,7 +2,6 @@ name = "bakery"
description = "Bread ecosystem package manager" description = "Bread ecosystem package manager"
binaries = ["bakery"] binaries = ["bakery"]
system_deps = [] system_deps = []
optional_system_deps = []
bread_deps = [] bread_deps = []
[install] [install]

View file

@ -18,6 +18,3 @@ sha2 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,45 +1,37 @@
use anyhow::Result; use anyhow::Result;
use std::process::Command; use std::process::Command;
pub struct DepReport { /// Check whether a list of system dependencies are present.
/// Required deps that are not present — blocks install. /// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
pub missing: Vec<String>, pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
/// Optional deps that are not present — advisory only, never blocks. let mut missing = Vec::new();
pub warnings: Vec<String>, for dep in deps {
if !dep_present(dep) {
missing.push(dep.clone());
}
}
Ok(missing)
} }
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> { fn dep_present(dep: &str) -> bool {
Ok(DepReport { // Try `which` first (covers executables like `iw`, `nmcli`).
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), if which(dep) {
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
})
}
fn dep_present(pkg: &str) -> bool {
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
if pacman_installed(pkg) {
return true; return true;
} }
// Fallback for environments without pacman: native PATH search then pkg-config. // Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
path_has(pkg) || pkg_config_exists(pkg) pkg_config_exists(dep)
} }
fn pacman_installed(pkg: &str) -> bool { fn which(bin: &str) -> bool {
Command::new("pacman") Command::new("which")
.args(["-Q", pkg]) .arg(bin)
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false)
} }
/// Check PATH without shelling out to `which` (avoids the external dependency).
fn path_has(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
fn pkg_config_exists(lib: &str) -> bool { fn pkg_config_exists(lib: &str) -> bool {
// Arch package names map directly to pkg-config names for GTK libs.
Command::new("pkg-config") Command::new("pkg-config")
.arg("--exists") .arg("--exists")
.arg(lib) .arg(lib)
@ -48,90 +40,33 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Print a formatted doctor report for a package's system deps. /// Print a formatted doctor report for a list of system deps.
/// Returns true if all *required* deps are satisfied. /// Returns true if all deps are satisfied.
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { pub fn report(package_name: &str, deps: &[String]) -> bool {
if required.is_empty() && optional.is_empty() { if deps.is_empty() {
println!(" {package_name}: no system deps required"); println!(" {package_name}: no system deps required");
return true; return true;
} }
match check_deps(required, optional) { match check_deps(deps) {
Err(e) => { Err(e) => {
eprintln!(" error running doctor for {package_name}: {e}"); eprintln!(" error running doctor: {e}");
false false
} }
Ok(rep) => { Ok(missing) => {
for warn in &rep.warnings { if missing.is_empty() {
eprintln!( println!(" {package_name}: all system deps satisfied");
" {package_name}: optional dep not found: {warn} \
(install for full functionality)"
);
}
if rep.missing.is_empty() {
println!(" {package_name}: all required system deps satisfied");
true true
} else { } else {
eprintln!( eprintln!(
" {package_name}: missing system deps: {}", " {package_name}: missing system deps: {}",
rep.missing.join(", ") missing.join(", ")
);
eprintln!(
" install with: sudo pacman -S {}",
missing.join(" ")
); );
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false false
} }
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_deps_pass() {
let rep = check_deps(&[], &[]).unwrap();
assert!(rep.missing.is_empty());
assert!(rep.warnings.is_empty());
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn pacman_finds_itself() {
assert!(pacman_installed("pacman"));
}
#[test]
fn path_has_finds_sh() {
assert!(path_has("sh"));
}
#[test]
fn missing_required_dep_detected() {
let rep = check_deps(
&["this-package-does-not-exist-xyzzy42".to_string()],
&[],
)
.unwrap();
assert_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty());
}
#[test]
fn missing_optional_dep_becomes_warning_not_error() {
let rep = check_deps(
&[],
&["this-package-does-not-exist-xyzzy42".to_string()],
)
.unwrap();
assert!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1);
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn installed_dep_not_missing() {
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
assert!(rep.missing.is_empty());
}
}

View file

@ -45,34 +45,3 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
#[test]
fn verify_correct_hash() {
let bytes = b"hello bakery";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
#[test]
fn verify_wrong_hash_fails() {
let bytes = b"hello bakery";
let wrong = "0".repeat(64);
assert!(verify_sha256(bytes, &wrong).is_err());
}
#[test]
fn verify_empty_bytes() {
let bytes = b"";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
}

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::download::fetch_and_place; use crate::download::fetch_and_place;
use crate::manifest::{fetch_binary, Package, Service}; use crate::manifest::{Package, Service};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
@ -18,15 +18,15 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
binary_names.push(install_name.to_string()); binary_names.push(install_name.to_string());
} }
// 2. Scaffold config dir + download example file. // 2. Scaffold config dir + example file.
if let Some(cfg) = &pkg.config { if let Some(cfg) = &pkg.config {
scaffold_config(cfg, pkg)?; scaffold_config(cfg)?;
} }
// 3. Install systemd user units. // 3. Install systemd user units.
let mut service_names = Vec::new(); let mut service_names = Vec::new();
for svc in &pkg.services { for svc in &pkg.services {
install_service(svc, bin_dir, pkg)?; install_service(svc, bin_dir)?;
service_names.push(svc.unit.clone()); service_names.push(svc.unit.clone());
} }
@ -60,8 +60,6 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
return Ok(()); return Ok(());
} }
}; };
// Commit removal immediately — file cleanup below is best-effort.
state.save()?;
// Remove binaries. // Remove binaries.
for bin in &installed.binaries { for bin in &installed.binaries {
@ -106,111 +104,66 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
println!(" data preserved at {}", data_dir.display()); println!(" data preserved at {}", data_dir.display());
} }
state.save()?;
println!(" {pkg_name} removed"); println!(" {pkg_name} removed");
Ok(()) Ok(())
} }
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> { fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> {
let dir = expand_tilde(&cfg.dir); let dir = expand_tilde(&cfg.dir);
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
if let Some(example) = &cfg.example { if let Some(example) = &cfg.example {
let dest = dir.join(example); let dest = dir.join(example);
if !dest.exists() { if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) { // We don't have the actual example file here at install time —
match fetch_binary(&primary, &fallback) { // the product repo's release bundle should include it.
Ok(bytes) => { // For now just note it; release.yml will bundle example configs.
std::fs::write(&dest, &bytes) println!(" config dir ready at {}", dir.display());
.with_context(|| format!("writing {}", dest.display()))?; println!(
println!(" installed example config at {}", dest.display()); " copy your {example} to {} to configure {}",
} dest.display(),
Err(e) => { dir.display()
eprintln!(" warning: could not download example config {example}: {e}"); );
println!(" config dir created at {}", dir.display());
}
}
} else {
println!(" config dir created at {}", dir.display());
}
} else { } else {
println!(" config at {} already exists, skipping", dest.display()); println!(" config at {} already exists, skipping", dest.display());
} }
} else {
println!(" config dir created at {}", dir.display());
} }
Ok(()) Ok(())
} }
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> {
let service_dir = systemd_user_dir(); let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?; std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit); let unit_path = service_dir.join(&svc.unit);
// Download the unit file if not already present. // The unit file is expected to be bundled alongside the binary in the
if !unit_path.exists() { // release artifact (or embedded). For now, patch ExecStart if the unit
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { // already exists (same pattern as bread/scripts/install.sh).
match fetch_binary(&primary, &fallback) { if unit_path.exists() {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}
}
} else {
eprintln!(" warning: no artifact URL to download {}", svc.unit);
}
}
if !unit_path.exists() {
eprintln!(
" warning: unit file {} not found — skipping service setup",
svc.unit
);
return Ok(());
}
patch_exec_start(&unit_path, bin_dir)?; patch_exec_start(&unit_path, bin_dir)?;
if !Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!(" warning: systemctl daemon-reload failed");
} }
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if svc.enable { if svc.enable {
let already_active = Command::new("systemctl") if Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit]) .args(["--user", "is-active", "--quiet", &svc.unit])
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false);
if already_active {
if Command::new("systemctl")
.args(["--user", "restart", &svc.unit])
.status()
.map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
let _ = Command::new("systemctl")
.args(["--user", "restart", &svc.unit])
.status();
println!(" {} restarted", svc.unit); println!(" {} restarted", svc.unit);
} else { } else {
eprintln!(" warning: failed to restart {}", svc.unit); let _ = Command::new("systemctl")
}
} else if Command::new("systemctl")
.args(["--user", "enable", "--now", &svc.unit]) .args(["--user", "enable", "--now", &svc.unit])
.status() .status();
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} enabled and started", svc.unit); println!(" {} enabled and started", svc.unit);
} else {
eprintln!(" warning: failed to enable {}", svc.unit);
} }
} }
@ -223,6 +176,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
.lines() .lines()
.map(|line| { .map(|line| {
if line.trim_start().starts_with("ExecStart=") { if line.trim_start().starts_with("ExecStart=") {
// Replace only the path prefix, keep args.
let rest = line.splitn(2, '=').nth(1).unwrap_or(""); let rest = line.splitn(2, '=').nth(1).unwrap_or("");
let argv: Vec<&str> = rest.split_whitespace().collect(); let argv: Vec<&str> = rest.split_whitespace().collect();
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) { if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
@ -242,13 +196,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
// Preserve trailing newline if the original had one. std::fs::write(unit_path, patched)?;
let output = if text.ends_with('\n') {
format!("{patched}\n")
} else {
patched
};
std::fs::write(unit_path, output)?;
Ok(()) Ok(())
} }
@ -293,7 +241,7 @@ fn expand_tilde(path: &str) -> PathBuf {
} }
} }
pub fn strip_arch_suffix(name: &str) -> &str { fn strip_arch_suffix(name: &str) -> &str {
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"]; const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
for s in SUFFIXES { for s in SUFFIXES {
if let Some(base) = name.strip_suffix(s) { if let Some(base) = name.strip_suffix(s) {
@ -314,53 +262,3 @@ fn warn_path_if_needed(bin_dir: &Path) {
println!(" export PATH=\"{}:$PATH\"", bin_str); println!(" export PATH=\"{}:$PATH\"", bin_str);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn strip_known_suffixes() {
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-aarch64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-arm64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-armv7"), "breadd");
assert_eq!(strip_arch_suffix("bakery-x86_64"), "bakery");
assert_eq!(strip_arch_suffix("breadd"), "breadd");
}
#[test]
fn patch_exec_start_with_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/bin arg1 arg2\n").unwrap();
patch_exec_start(&path, Path::new("/new/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/new/bin/bin arg1 arg2"));
assert!(out.ends_with('\n'));
}
#[test]
fn patch_exec_start_no_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/daemon\n").unwrap();
patch_exec_start(&path, Path::new("/usr/local/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/usr/local/bin/daemon"));
assert!(!out.contains("daemon "));
}
#[test]
fn patch_exec_start_non_exec_lines_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Unit]\nDescription=foo\nExecStart=/bin/foo\n").unwrap();
patch_exec_start(&path, Path::new("/usr/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("Description=foo"));
assert!(out.contains("ExecStart=/usr/bin/foo"));
}
}

View file

@ -6,11 +6,10 @@ mod state;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] #[command(name = "bakery", about = "Package manager for the bread ecosystem")]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Cmd, command: Cmd,
@ -21,10 +20,9 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Install one or more packages /// Install a package
Install { Install {
#[arg(required = true, num_args = 1..)] package: String,
packages: Vec<String>,
}, },
/// Remove an installed package (data files are never deleted) /// Remove an installed package (data files are never deleted)
Remove { Remove {
@ -32,12 +30,8 @@ enum Cmd {
}, },
/// Update one or all installed packages /// Update one or all installed packages
Update { Update {
/// Package to update (omit or use --all to update everything installed) /// Package to update; omit to update all installed packages
#[arg(conflicts_with = "all")]
package: Option<String>, package: Option<String>,
/// Update all installed packages
#[arg(long, conflicts_with = "package")]
all: bool,
}, },
/// List packages /// List packages
List { List {
@ -67,59 +61,27 @@ fn main() -> Result<()> {
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
match cli.command { match cli.command {
Cmd::Install { packages } => { Cmd::Install { package } => cmd_install(&package, &bin_dir),
let index = manifest::load(true)?;
for pkg in &packages {
cmd_install(&index, pkg, &bin_dir)?;
}
Ok(())
}
Cmd::Remove { package } => cmd_remove(&package, &bin_dir), Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
Cmd::List { installed } => cmd_list(installed), Cmd::List { installed } => cmd_list(installed),
Cmd::Info { package } => cmd_info(&package), Cmd::Info { package } => cmd_info(&package),
Cmd::Doctor { package } => cmd_doctor(package.as_deref()), Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
} }
} }
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> {
let mut visited = HashSet::new(); let index = manifest::load(false)?;
install_with_deps(index, name, bin_dir, &mut visited)
}
/// Recursively installs `name` and any bread_deps, skipping already-installed
/// packages. The `visited` set prevents cycles.
fn install_with_deps(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
visited: &mut HashSet<String>,
) -> Result<()> {
if !visited.insert(name.to_string()) {
return Ok(());
}
let pkg = index let pkg = index
.get(name) .get(name)
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
// Install bread_deps first (skip those already recorded in state). // Doctor runs first — bail if system deps are missing.
let state = state::State::load()?; println!("checking system dependencies…");
for dep in pkg.bread_deps.clone() { let missing = doctor::check_deps(&pkg.system_deps)?;
if !state.is_installed(&dep) { if !missing.is_empty() {
println!("installing bread dependency: {dep}"); eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
install_with_deps(index, &dep, bin_dir, visited)?; eprintln!("install with: sudo pacman -S {}", missing.join(" "));
}
}
println!("checking system dependencies for {name}");
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
eprintln!("install with: sudo pacman -S {}", rep.missing.join(" "));
bail!("system deps not satisfied"); bail!("system deps not satisfied");
} }
@ -130,22 +92,15 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
install::remove_package(name, bin_dir) install::remove_package(name, bin_dir)
} }
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?; let index = manifest::load(true)?; // force refresh on update
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = if all || name.is_none() { let targets: Vec<String> = match name {
state.packages.keys().cloned().collect() Some(n) => vec![n.to_string()],
} else { None => state.packages.keys().cloned().collect(),
vec![name.unwrap().to_string()]
}; };
if targets.is_empty() {
println!("no packages installed");
return Ok(());
}
let mut any_failed = false;
for pkg_name in &targets { for pkg_name in &targets {
let installed = match state.packages.get(pkg_name.as_str()) { let installed = match state.packages.get(pkg_name.as_str()) {
Some(p) => p, Some(p) => p,
@ -161,45 +116,15 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul
continue; continue;
} }
}; };
if installed.version == latest.version { if installed.version == latest.version {
println!("{pkg_name} is already at {}", installed.version); println!("{pkg_name} is already at {}", installed.version);
continue; } else {
}
println!( println!(
"updating {pkg_name} {} → {}", "updating {pkg_name} {} → {}",
installed.version, latest.version installed.version, latest.version
); );
install::install_package(latest, bin_dir)?;
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
Ok(r) => r,
Err(e) => {
eprintln!(" doctor check failed for {pkg_name}: {e}");
any_failed = true;
continue;
} }
};
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!(
" missing deps for {pkg_name}: {} — skipping update",
rep.missing.join(", ")
);
any_failed = true;
continue;
}
if let Err(e) = install::install_package(latest, bin_dir) {
eprintln!(" failed to update {pkg_name}: {e}");
any_failed = true;
}
}
if any_failed {
bail!("one or more packages could not be updated");
} }
Ok(()) Ok(())
} }
@ -248,32 +173,15 @@ fn cmd_info(name: &str) -> Result<()> {
println!("{} {}", pkg.name, pkg.version); println!("{} {}", pkg.name, pkg.version);
println!(" {}", pkg.description); println!(" {}", pkg.description);
println!(" status: {status}"); println!(" status: {status}");
println!( println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
" binaries: {}",
pkg.binaries
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !pkg.system_deps.is_empty() { if !pkg.system_deps.is_empty() {
println!(" system deps: {}", pkg.system_deps.join(", ")); println!(" system deps: {}", pkg.system_deps.join(", "));
} }
if !pkg.optional_system_deps.is_empty() {
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
}
if !pkg.bread_deps.is_empty() { if !pkg.bread_deps.is_empty() {
println!(" bread deps: {}", pkg.bread_deps.join(", ")); println!(" bread deps: {}", pkg.bread_deps.join(", "));
} }
if !pkg.services.is_empty() { if !pkg.services.is_empty() {
println!( println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
" services: {}",
pkg.services
.iter()
.map(|s| s.unit.as_str())
.collect::<Vec<_>>()
.join(", ")
);
} }
Ok(()) Ok(())
} }
@ -283,12 +191,7 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = match name { let targets: Vec<String> = match name {
Some(n) => { Some(n) => vec![n.to_string()],
if index.get(n).is_none() {
bail!("unknown package: {n}");
}
vec![n.to_string()]
}
None => state.packages.keys().cloned().collect(), None => state.packages.keys().cloned().collect(),
}; };
@ -300,12 +203,9 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let mut all_ok = true; let mut all_ok = true;
for pkg_name in &targets { for pkg_name in &targets {
if let Some(pkg) = index.get(pkg_name) { if let Some(pkg) = index.get(pkg_name) {
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { if !doctor::report(pkg_name, &pkg.system_deps) {
all_ok = false; all_ok = false;
} }
} else {
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
all_ok = false;
} }
} }

View file

@ -23,7 +23,7 @@ pub struct Service {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConfigScaffold { pub struct ConfigScaffold {
pub dir: String, pub dir: String,
/// Example config filename, relative to the release artifact directory. /// relative to the product repo root; copied as-is if absent at install time
pub example: Option<String>, pub example: Option<String>,
} }
@ -36,8 +36,6 @@ pub struct Package {
#[serde(default)] #[serde(default)]
pub system_deps: Vec<String>, pub system_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub optional_system_deps: Vec<String>,
#[serde(default)]
pub bread_deps: Vec<String>, pub bread_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub services: Vec<Service>, pub services: Vec<Service>,
@ -46,21 +44,6 @@ pub struct Package {
pub post_install: Vec<String>, pub post_install: Vec<String>,
} }
impl Package {
/// Returns `(primary_url, github_url)` for any artifact filename in this
/// package's release directory. Derived by stripping the filename from the
/// first binary's URLs.
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
let first = self.binaries.first()?;
let dl_base = first.dl_url.rsplit_once('/')?.0;
let gh_base = first.github_url.rsplit_once('/')?.0;
Some((
format!("{dl_base}/{filename}"),
format!("{gh_base}/{filename}"),
))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Index { pub struct Index {
pub version: String, pub version: String,
@ -84,7 +67,8 @@ pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path(); let cache_path = cache_path();
if !force_refresh && cache_is_fresh(&cache_path) { if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; let text = std::fs::read_to_string(&cache_path)
.context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index"); return serde_json::from_str(&text).context("parsing cached index");
} }
@ -148,6 +132,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
resp.into_reader() resp.into_reader()
.read_to_end(&mut buf) .read_to_end(&mut buf)
.context("reading response")?; .context("reading binary")?;
Ok(buf) Ok(buf)
} }

View file

@ -33,12 +33,7 @@ impl State {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
let text = serde_json::to_string_pretty(self)?; let text = serde_json::to_string_pretty(self)?;
// Write to a temp file then rename for atomicity — avoids a torn write std::fs::write(&path, text).context("writing installed.json")
// if the process is killed mid-save.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
Ok(())
} }
pub fn is_installed(&self, name: &str) -> bool { pub fn is_installed(&self, name: &str) -> bool {
@ -63,58 +58,3 @@ fn state_path() -> PathBuf {
}) })
.join("bakery/installed.json") .join("bakery/installed.json")
} }
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn record_and_is_installed() {
let mut state = State::default();
assert!(!state.is_installed("foo"));
state.record(pkg("foo", "1.0.0"));
assert!(state.is_installed("foo"));
}
#[test]
fn remove_installed() {
let mut state = State::default();
state.record(pkg("foo", "1.0.0"));
let removed = state.remove("foo");
assert!(removed.is_some());
assert!(!state.is_installed("foo"));
}
#[test]
fn remove_unknown_returns_none() {
let mut state = State::default();
assert!(state.remove("nope").is_none());
}
#[test]
fn json_roundtrip() {
let mut state = State::default();
state.record(InstalledPackage {
name: "bar".to_string(),
version: "2.0.0".to_string(),
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
}
}

View file

@ -7,11 +7,6 @@ description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland"
homepage = "https://breadway.dev" homepage = "https://breadway.dev"
dl_base = "https://dl.breadway.dev" dl_base = "https://dl.breadway.dev"
[[products]]
name = "bakery"
repo = "Breadway/bread-ecosystem"
description = "Bread ecosystem package manager"
[[products]] [[products]]
name = "bread" name = "bread"
repo = "Breadway/bread" repo = "Breadway/bread"

97
scripts/gen-index.sh Executable file → Normal file
View file

@ -1,28 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generate dl.breadway.dev/index.json from: # Generate dl.breadway.dev/index.json from:
# - registry/bread-ecosystem.toml (product list) # - registry/bread-ecosystem.toml (product list)
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml) # - <repo>/bakery.toml (per-product metadata)
# - <DL_DIR>/ (built binaries + sha256 files) # - /srv/breadway-dl/ (built binaries + sha256 files)
# #
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
# Run on hestia after each product build, before the dl server is refreshed. # Run on hestia after each product build, before the dl server is refreshed.
# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum # Requires: jq, python3 (for toml parsing via tomllib), sha256sum
set -euo pipefail set -euo pipefail
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_DIR="${DL_DIR:-/srv/breadway-dl}"
DL_BASE="${DL_BASE:-https://dl.breadway.dev}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
GH_BASE="https://github.com" GH_BASE="https://github.com"
OUT="${DL_DIR}/index.json" OUT="${DL_DIR}/index.json"
# Read the product list from the registry TOML instead of a hardcoded array. # Products are read from the registry. Each line is "name repo".
mapfile -t products < <(python3 -c " products=(
import tomllib, sys "bakery Breadway/bread-ecosystem"
with open('${SCRIPT_DIR}/registry/bread-ecosystem.toml', 'rb') as f: "bread Breadway/bread"
d = tomllib.load(f) "breadbar Breadway/breadbar"
for p in d['products']: "breadbox Breadway/breadbox"
print(p['name'], p['repo']) "breadcrumbs Breadway/breadcrumbs"
") "breadpad Breadway/breadpad"
)
# Build a JSON package entry for one product. # Build a JSON package entry for one product.
# $1 = product name, $2 = github repo slug # $1 = product name, $2 = github repo slug
@ -34,14 +34,14 @@ build_package_json() {
local pkg_dir="${DL_DIR}/${name}" local pkg_dir="${DL_DIR}/${name}"
if [[ ! -d "${pkg_dir}" ]]; then if [[ ! -d "${pkg_dir}" ]]; then
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
return 1 return
fi fi
# The latest symlink must point to the current version dir. # The latest symlink must point to the current version dir.
local latest_link="${pkg_dir}/latest" local latest_link="${pkg_dir}/latest"
if [[ ! -L "${latest_link}" ]]; then if [[ ! -L "${latest_link}" ]]; then
echo " warning: no 'latest' symlink for ${name}" >&2 echo " warning: no 'latest' symlink for ${name}" >&2
return 1 return
fi fi
local version_dir local version_dir
version_dir="$(readlink -f "${latest_link}")" version_dir="$(readlink -f "${latest_link}")"
@ -77,78 +77,45 @@ build_package_json() {
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
done done
# Locate bakery.toml. The release workflow copies it into the version dir # Read bakery.toml: the release workflow copies it to DL_DIR alongside the
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a # binaries; fall back to a sibling checkout for local dev use.
# sibling repo checkout for local dev use. local bakery_toml="${DL_DIR}/${name}/bakery.toml"
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi fi
if [[ ! -f "${bakery_toml}" ]]; then local description=""
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 local system_deps="[]"
return 1 local bread_deps="[]"
fi local services="[]"
local config="null"
local description system_deps optional_system_deps bread_deps services config post_install local post_install="[]"
if [[ -f "${bakery_toml}" ]]; then
description="$(python3 -c " description="$(python3 -c "
import tomllib import tomllib, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(d.get('description', '')) print(d.get('description', ''))
" 2>/dev/null || true)" " 2>/dev/null || true)"
system_deps="$(python3 -c " system_deps="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('system_deps', []))) print(json.dumps(d.get('system_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
optional_system_deps="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(json.dumps(d.get('optional_system_deps', [])))
" 2>/dev/null || echo "[]")"
bread_deps="$(python3 -c " bread_deps="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('bread_deps', []))) print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
# [[service]] entries → [{unit, enable}]
services="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
svcs = d.get('service', [])
print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")"
# [config] → {dir, example?} or null
config="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
cfg = d.get('config')
if cfg:
obj = {'dir': cfg['dir']}
if 'example' in cfg:
obj['example'] = cfg['example']
print(json.dumps(obj))
else:
print('null')
" 2>/dev/null || echo "null")"
post_install="$(python3 -c " post_install="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('install', {}).get('post_install', []))) print(json.dumps(d.get('install', {}).get('post_install', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
fi
jq -n \ jq -n \
--arg name "${name}" \ --arg name "${name}" \
@ -156,10 +123,8 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--arg version "${version}" \ --arg version "${version}" \
--argjson binaries "${binaries_json}" \ --argjson binaries "${binaries_json}" \
--argjson system_deps "${system_deps}" \ --argjson system_deps "${system_deps}" \
--argjson optional_system_deps "${optional_system_deps}" \
--argjson bread_deps "${bread_deps}" \ --argjson bread_deps "${bread_deps}" \
--argjson services "${services}" \ --argjson services "${services}" \
--argjson config "${config}" \
--argjson post_install "${post_install}" \ --argjson post_install "${post_install}" \
'{ '{
name: $name, name: $name,
@ -167,10 +132,8 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
version: $version, version: $version,
binaries: $binaries, binaries: $binaries,
system_deps: $system_deps, system_deps: $system_deps,
optional_system_deps: $optional_system_deps,
bread_deps: $bread_deps, bread_deps: $bread_deps,
services: $services, services: $services,
config: $config,
post_install: $post_install post_install: $post_install
}' }'
} }

33
scripts/get.sh Executable file → Normal file
View file

@ -1,10 +1,12 @@
#!/bin/sh #!/bin/sh
# Bootstrap script: downloads and installs the `bakery` binary. # Bootstrap script: installs the `bakery` binary.
# Usage: curl https://breadway.dev/get | sh # Usage: curl https://breadway.dev/get | sh
# Or: curl -sSfL https://breadway.dev/get | sh # Or: curl -sSfL https://breadway.dev/get | sh
set -eu set -eu
BAKERY_VERSION="${BAKERY_VERSION:-latest}" BAKERY_VERSION="${BAKERY_VERSION:-latest}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${BAKERY_VERSION}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/${BAKERY_VERSION}/bakery-x86_64"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
@ -13,20 +15,6 @@ die() { echo "error: $*" >&2; exit 1; }
uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))" uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))" uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
# Build download URLs. GitHub's "latest" redirect lives at a different path from
# versioned releases, so we handle them separately and always prefix tags with 'v'.
if [ "${BAKERY_VERSION}" = "latest" ]; then
DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256"
else
# Strip a leading 'v' if the caller included it, then add it back consistently.
ver="${BAKERY_VERSION#v}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256"
fi
# Pick a download tool. # Pick a download tool.
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1" -o "$2"; } fetch() { curl -fsSL "$1" -o "$2"; }
@ -38,26 +26,13 @@ fi
mkdir -p "${BIN_DIR}" mkdir -p "${BIN_DIR}"
TMP="$(mktemp)" TMP="$(mktemp)"
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT trap 'rm -f "${TMP}"' EXIT
echo "downloading bakery…" echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev" echo " from dl.breadway.dev"
# Verify checksum when available from primary.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo " warning: could not fetch checksum — skipping verification"
fi
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)" echo " from GitHub (fallback)"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified for GitHub fallback download"
else else
die "failed to download bakery from both primary and fallback URLs" die "failed to download bakery from both primary and fallback URLs"
fi fi

View file

@ -1,113 +0,0 @@
#!/usr/bin/env bash
# Smoke-test gen-index.sh against a minimal fixture DL_DIR tree.
# Verifies that services, config, system_deps, optional_system_deps,
# description, and post_install are all populated correctly.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIXTURE="$(mktemp -d)"
FAKE_REGISTRY="$(mktemp -d)"
trap 'rm -rf "${FIXTURE}" "${FAKE_REGISTRY}"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
# ── Build a minimal release tree for "fakepkg" ───────────────────────────────
PKG_VER_DIR="${FIXTURE}/fakepkg/0.1.0"
mkdir -p "${PKG_VER_DIR}"
printf 'fake-binary-content' > "${PKG_VER_DIR}/fakepkg-x86_64"
sha256sum "${PKG_VER_DIR}/fakepkg-x86_64" | awk '{print $1}' \
> "${PKG_VER_DIR}/fakepkg-x86_64.sha256"
printf '[Unit]\nDescription=fakepkg\n' > "${PKG_VER_DIR}/fakepkg.service"
printf '# example config\n' > "${PKG_VER_DIR}/fakepkg.example.toml"
cat > "${PKG_VER_DIR}/bakery.toml" <<'TOML'
name = "fakepkg"
description = "A fake package for testing"
binaries = ["fakepkg"]
system_deps = ["gtk4"]
optional_system_deps = ["hyprland"]
bread_deps = []
[[service]]
unit = "fakepkg.service"
enable = true
[config]
dir = "~/.config/fakepkg"
example = "fakepkg.example.toml"
[install]
post_install = ["echo installed"]
TOML
# gen-index looks for bakery.toml at ${DL_DIR}/<name>/bakery.toml (no version)
cp "${PKG_VER_DIR}/bakery.toml" "${FIXTURE}/fakepkg/bakery.toml"
ln -s "${PKG_VER_DIR}" "${FIXTURE}/fakepkg/latest"
# ── Minimal registry pointing only at fakepkg ────────────────────────────────
mkdir -p "${FAKE_REGISTRY}/registry"
cat > "${FAKE_REGISTRY}/registry/bread-ecosystem.toml" <<'TOML'
[ecosystem]
name = "test"
[[products]]
name = "fakepkg"
repo = "Test/fakepkg"
description = "A fake package"
TOML
# ── Run gen-index with overridden SCRIPT_DIR and DL_DIR ──────────────────────
OUT="${FIXTURE}/index.json"
SCRIPT_DIR="${FAKE_REGISTRY}" DL_DIR="${FIXTURE}" DL_BASE="https://dl.test" \
bash "${REPO_ROOT}/scripts/gen-index.sh" 2>&1 | sed 's/^/ [gen-index] /'
[[ -f "${OUT}" ]] || fail "index.json was not produced"
# ── Assertions ────────────────────────────────────────────────────────────────
jq -e '.packages.fakepkg' "${OUT}" > /dev/null \
|| fail "fakepkg missing from index"
check() {
local label="$1" expected="$2" actual="$3"
[[ "${actual}" == "${expected}" ]] \
|| fail "${label}: expected '${expected}', got '${actual}'"
}
check "description" \
"A fake package for testing" \
"$(jq -r '.packages.fakepkg.description' "${OUT}")"
check "system_deps" \
"gtk4" \
"$(jq -r '.packages.fakepkg.system_deps | join(",")' "${OUT}")"
check "optional_system_deps" \
"hyprland" \
"$(jq -r '.packages.fakepkg.optional_system_deps | join(",")' "${OUT}")"
check "services[0].unit" \
"fakepkg.service" \
"$(jq -r '.packages.fakepkg.services[0].unit' "${OUT}")"
check "services[0].enable" \
"true" \
"$(jq -r '.packages.fakepkg.services[0].enable' "${OUT}")"
check "config.dir" \
"~/.config/fakepkg" \
"$(jq -r '.packages.fakepkg.config.dir' "${OUT}")"
check "config.example" \
"fakepkg.example.toml" \
"$(jq -r '.packages.fakepkg.config.example' "${OUT}")"
check "binaries[0].name" \
"fakepkg-x86_64" \
"$(jq -r '.packages.fakepkg.binaries[0].name' "${OUT}")"
check "post_install[0]" \
"echo installed" \
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")"
echo "OK: all gen-index assertions passed"