fix: comprehensive bakery package manager audit and repair

Critical fixes:
- gen-index.sh: emit services, config, optional_system_deps from bakery.toml;
  parse product list from registry TOML instead of hardcoded array; fail loudly
  when bakery.toml is missing (was silently producing empty metadata in prod)
- install.rs: download service units and example configs from dl server at
  install time (were never fetched); check systemctl exit codes (were swallowed);
  save state before file cleanup in remove_package (was inconsistent on error)
- doctor.rs: rewrite dep detection to use `pacman -Q` as primary (no more
  dependency on `which` or pkg-config name mismatches); add optional_system_deps
  support returning (missing, warnings) — warnings print but never block install
- get.sh: fix GitHub fallback URL (was 404 for both latest and versioned
  releases); add SHA-256 checksum verification using published .sha256 file

High priority fixes:
- bakery doctor <unknown-pkg>: exit non-zero (was silently passing)
- bakery update: add --all flag (documented in README but missing from CLI);
  add doctor gate before update (was bypassing dep check)
- bread_deps: now resolved recursively with cycle detection (was ignored)
- manifest.rs: add artifact_urls() helper and optional_system_deps field
- state.rs: atomic save via tmp+rename; cmd_info shows optional_system_deps

Tests: 17 new unit tests across doctor, download, install, state modules;
scripts/test-gen-index.sh fixture test for full pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Breadway 2026-06-11 13:37:09 +08:00
parent a4ea036a7c
commit a8be86be03
13 changed files with 971 additions and 148 deletions

265
Cargo.lock generated
View file

@ -91,6 +91,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"tempfile",
"toml 0.8.23",
"ureq",
]
@ -322,6 +323,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "field-offset"
version = "0.3.6"
@ -348,6 +365,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -497,6 +520,19 @@ dependencies = [
"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]]
name = "gio"
version = "0.22.6"
@ -687,6 +723,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@ -811,6 +856,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@ -839,7 +890,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@ -866,6 +919,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@ -881,6 +940,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@ -996,6 +1061,16 @@ dependencies = [
"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]]
name = "proc-macro-crate"
version = "3.5.0"
@ -1023,13 +1098,19 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"libredox",
"thiserror",
]
@ -1042,7 +1123,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@ -1057,6 +1138,19 @@ dependencies = [
"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]]
name = "rustls"
version = "0.23.40"
@ -1259,6 +1353,19 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "thiserror"
version = "1.0.69"
@ -1393,6 +1500,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -1459,6 +1572,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasm-bindgen"
version = "0.2.122"
@ -1504,6 +1635,40 @@ dependencies = [
"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]]
name = "webpki-roots"
version = "0.26.11"
@ -1747,6 +1912,100 @@ dependencies = [
"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]]
name = "writeable"
version = "0.6.3"

View file

@ -49,13 +49,18 @@ bakery remove <pkg> # remove a package (data files are never deleted)
## System dependencies by product
| Package | Arch packages |
|---------|--------------|
| `bread` | `libudev` `dbus` |
| `breadbar` | `gtk4` `gtk4-layer-shell` `dbus` `iw` |
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` |
| `breadcrumbs` | `networkmanager` |
| `breadpad` | `gtk4` `gtk4-layer-shell` `dbus` |
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
| Package | Required | Optional |
|---------|----------|---------|
| `bakery` | _(statically linked, none)_ | — |
| `bread` | `systemd-libs` `openssl` `zlib` | `bluez` `hyprland` |
| `breadbar` | `gtk4` `gtk4-layer-shell` `iw` `libpulse` | `hyprland` |
| `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
@ -90,6 +95,22 @@ and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back
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
MIT

View file

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

View file

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

View file

@ -1,37 +1,45 @@
use anyhow::Result;
use std::process::Command;
/// Check whether a list of system dependencies are present.
/// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
let mut missing = Vec::new();
for dep in deps {
if !dep_present(dep) {
missing.push(dep.clone());
}
}
Ok(missing)
pub struct DepReport {
/// Required deps that are not present — blocks install.
pub missing: Vec<String>,
/// Optional deps that are not present — advisory only, never blocks.
pub warnings: Vec<String>,
}
fn dep_present(dep: &str) -> bool {
// Try `which` first (covers executables like `iw`, `nmcli`).
if which(dep) {
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
Ok(DepReport {
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
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;
}
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
pkg_config_exists(dep)
// Fallback for environments without pacman: native PATH search then pkg-config.
path_has(pkg) || pkg_config_exists(pkg)
}
fn which(bin: &str) -> bool {
Command::new("which")
.arg(bin)
fn pacman_installed(pkg: &str) -> bool {
Command::new("pacman")
.args(["-Q", pkg])
.output()
.map(|o| o.status.success())
.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 {
// Arch package names map directly to pkg-config names for GTK libs.
Command::new("pkg-config")
.arg("--exists")
.arg(lib)
@ -40,33 +48,88 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false)
}
/// Print a formatted doctor report for a list of system deps.
/// Returns true if all deps are satisfied.
pub fn report(package_name: &str, deps: &[String]) -> bool {
if deps.is_empty() {
/// Print a formatted doctor report for a package's system deps.
/// Returns true if all *required* deps are satisfied.
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
if required.is_empty() && optional.is_empty() {
println!(" {package_name}: no system deps required");
return true;
}
match check_deps(deps) {
match check_deps(required, optional) {
Err(e) => {
eprintln!(" error running doctor: {e}");
eprintln!(" error running doctor for {package_name}: {e}");
false
}
Ok(missing) => {
if missing.is_empty() {
println!(" {package_name}: all system deps satisfied");
Ok(rep) => {
for warn in &rep.warnings {
eprintln!(
" {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
} else {
eprintln!(
" {package_name}: missing system deps: {}",
missing.join(", ")
);
eprintln!(
" install with: sudo pacman -S {}",
missing.join(" ")
rep.missing.join(", ")
);
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
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());
}
#[test]
fn pacman_finds_itself() {
// pacman is always installed on Arch — verifies our detection path works.
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);
}
#[test]
fn installed_dep_not_missing() {
// "pacman" is definitely installed; should not appear in missing.
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
assert!(rep.missing.is_empty());
}
}

View file

@ -45,3 +45,34 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
}
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 crate::download::fetch_and_place;
use crate::manifest::{Package, Service};
use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State};
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());
}
// 2. Scaffold config dir + example file.
// 2. Scaffold config dir + download example file.
if let Some(cfg) = &pkg.config {
scaffold_config(cfg)?;
scaffold_config(cfg, pkg)?;
}
// 3. Install systemd user units.
let mut service_names = Vec::new();
for svc in &pkg.services {
install_service(svc, bin_dir)?;
install_service(svc, bin_dir, pkg)?;
service_names.push(svc.unit.clone());
}
@ -60,6 +60,8 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
return Ok(());
}
};
// Commit removal immediately — file cleanup below is best-effort.
state.save()?;
// Remove binaries.
for bin in &installed.binaries {
@ -104,66 +106,111 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
println!(" data preserved at {}", data_dir.display());
}
state.save()?;
println!(" {pkg_name} removed");
Ok(())
}
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> {
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> {
let dir = expand_tilde(&cfg.dir);
std::fs::create_dir_all(&dir)?;
if let Some(example) = &cfg.example {
let dest = dir.join(example);
if !dest.exists() {
// We don't have the actual example file here at install time —
// the product repo's release bundle should include it.
// For now just note it; release.yml will bundle example configs.
println!(" config dir ready at {}", dir.display());
println!(
" copy your {example} to {} to configure {}",
dest.display(),
dir.display()
);
if let Some((primary, fallback)) = pkg.artifact_urls(example) {
match fetch_binary(&primary, &fallback) {
Ok(bytes) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Err(e) => {
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 {
println!(" config at {} already exists, skipping", dest.display());
}
} else {
println!(" config dir created at {}", dir.display());
}
Ok(())
}
fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> {
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit);
// The unit file is expected to be bundled alongside the binary in the
// release artifact (or embedded). For now, patch ExecStart if the unit
// already exists (same pattern as bread/scripts/install.sh).
if unit_path.exists() {
patch_exec_start(&unit_path, bin_dir)?;
// Download the unit file if not already present.
if !unit_path.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
match fetch_binary(&primary, &fallback) {
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);
}
}
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if !unit_path.exists() {
eprintln!(
" warning: unit file {} not found — skipping service setup",
svc.unit
);
return Ok(());
}
if svc.enable {
if Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit])
patch_exec_start(&unit_path, bin_dir)?;
if !Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
let _ = Command::new("systemctl")
eprintln!(" warning: systemctl daemon-reload failed");
}
if svc.enable {
let already_active = Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit])
.status()
.map(|s| s.success())
.unwrap_or(false);
if already_active {
if Command::new("systemctl")
.args(["--user", "restart", &svc.unit])
.status();
.status()
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} restarted", svc.unit);
} else {
let _ = Command::new("systemctl")
eprintln!(" warning: failed to restart {}", svc.unit);
}
} else if Command::new("systemctl")
.args(["--user", "enable", "--now", &svc.unit])
.status();
.status()
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} enabled and started", svc.unit);
} else {
eprintln!(" warning: failed to enable {}", svc.unit);
}
}
@ -176,7 +223,6 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
.lines()
.map(|line| {
if line.trim_start().starts_with("ExecStart=") {
// Replace only the path prefix, keep args.
let rest = line.splitn(2, '=').nth(1).unwrap_or("");
let argv: Vec<&str> = rest.split_whitespace().collect();
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
@ -196,7 +242,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(unit_path, patched)?;
// Preserve trailing newline if the original had one.
let output = if text.ends_with('\n') {
format!("{patched}\n")
} else {
patched
};
std::fs::write(unit_path, output)?;
Ok(())
}
@ -241,7 +293,7 @@ fn expand_tilde(path: &str) -> PathBuf {
}
}
fn strip_arch_suffix(name: &str) -> &str {
pub fn strip_arch_suffix(name: &str) -> &str {
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
for s in SUFFIXES {
if let Some(base) = name.strip_suffix(s) {
@ -262,3 +314,53 @@ fn warn_path_if_needed(bin_dir: &Path) {
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,6 +6,7 @@ mod state;
use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Parser)]
@ -31,8 +32,12 @@ enum Cmd {
},
/// Update one or all installed packages
Update {
/// Package to update; omit to update all installed packages
/// Package to update (omit or use --all to update everything installed)
#[arg(conflicts_with = "all")]
package: Option<String>,
/// Update all installed packages
#[arg(long, conflicts_with = "package")]
all: bool,
},
/// List packages
List {
@ -70,7 +75,7 @@ fn main() -> Result<()> {
Ok(())
}
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir),
Cmd::List { installed } => cmd_list(installed),
Cmd::Info { package } => cmd_info(&package),
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
@ -78,16 +83,43 @@ fn main() -> Result<()> {
}
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> {
let mut visited = HashSet::new();
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
.get(name)
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
// Doctor runs first — bail if system deps are missing.
println!("checking system dependencies…");
let missing = doctor::check_deps(&pkg.system_deps)?;
if !missing.is_empty() {
eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
eprintln!("install with: sudo pacman -S {}", missing.join(" "));
// Install bread_deps first (skip those already recorded in state).
let state = state::State::load()?;
for dep in pkg.bread_deps.clone() {
if !state.is_installed(&dep) {
println!("installing bread dependency: {dep}");
install_with_deps(index, &dep, bin_dir, visited)?;
}
}
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");
}
@ -98,16 +130,22 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
install::remove_package(name, bin_dir)
}
fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?; // force refresh on update
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?;
let state = state::State::load()?;
let effective = name.filter(|&n| n != "all");
let targets: Vec<String> = match effective {
Some(n) => vec![n.to_string()],
None => state.packages.keys().cloned().collect(),
let targets: Vec<String> = if all || name.is_none() {
state.packages.keys().cloned().collect()
} else {
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 {
let installed = match state.packages.get(pkg_name.as_str()) {
Some(p) => p,
@ -123,15 +161,45 @@ fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
continue;
}
};
if installed.version == latest.version {
println!("{pkg_name} is already at {}", installed.version);
} else {
continue;
}
println!(
"updating {pkg_name} {} → {}",
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(())
}
@ -180,15 +248,32 @@ fn cmd_info(name: &str) -> Result<()> {
println!("{} {}", pkg.name, pkg.version);
println!(" {}", pkg.description);
println!(" status: {status}");
println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
println!(
" binaries: {}",
pkg.binaries
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !pkg.system_deps.is_empty() {
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() {
println!(" bread deps: {}", pkg.bread_deps.join(", "));
}
if !pkg.services.is_empty() {
println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
println!(
" services: {}",
pkg.services
.iter()
.map(|s| s.unit.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(())
}
@ -198,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let state = state::State::load()?;
let targets: Vec<String> = match name {
Some(n) => vec![n.to_string()],
Some(n) => {
if index.get(n).is_none() {
bail!("unknown package: {n}");
}
vec![n.to_string()]
}
None => state.packages.keys().cloned().collect(),
};
@ -210,9 +300,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let mut all_ok = true;
for pkg_name in &targets {
if let Some(pkg) = index.get(pkg_name) {
if !doctor::report(pkg_name, &pkg.system_deps) {
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
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)]
pub struct ConfigScaffold {
pub dir: String,
/// relative to the product repo root; copied as-is if absent at install time
/// Example config filename, relative to the release artifact directory.
pub example: Option<String>,
}
@ -36,6 +36,8 @@ pub struct Package {
#[serde(default)]
pub system_deps: Vec<String>,
#[serde(default)]
pub optional_system_deps: Vec<String>,
#[serde(default)]
pub bread_deps: Vec<String>,
#[serde(default)]
pub services: Vec<Service>,
@ -44,6 +46,21 @@ pub struct Package {
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)]
pub struct Index {
pub version: String,
@ -67,8 +84,7 @@ pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = 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");
}
@ -132,6 +148,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.context("reading binary")?;
.context("reading response")?;
Ok(buf)
}

View file

@ -33,7 +33,12 @@ impl State {
std::fs::create_dir_all(dir)?;
}
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&path, text).context("writing installed.json")
// Write to a temp file then rename for atomicity — avoids a torn write
// 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 {
@ -58,3 +63,58 @@ fn state_path() -> PathBuf {
})
.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"]);
}
}

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

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

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

@ -1,12 +1,10 @@
#!/bin/sh
# Bootstrap script: installs the `bakery` binary.
# Bootstrap script: downloads and installs the `bakery` binary.
# Usage: curl https://breadway.dev/get | sh
# Or: curl -sSfL https://breadway.dev/get | sh
set -eu
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}"
die() { echo "error: $*" >&2; exit 1; }
@ -15,6 +13,20 @@ die() { echo "error: $*" >&2; exit 1; }
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))"
# 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.
if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1" -o "$2"; }
@ -26,13 +38,26 @@ fi
mkdir -p "${BIN_DIR}"
TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT
echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
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
echo " from GitHub (fallback)"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified for GitHub fallback download"
else
die "failed to download bakery from both primary and fallback URLs"
fi

113
scripts/test-gen-index.sh Executable file
View file

@ -0,0 +1,113 @@
#!/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"