diff --git a/Cargo.lock b/Cargo.lock index 78dd279..36707a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.1.0" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -91,6 +91,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "toml 0.8.23", "ureq", ] @@ -118,7 +119,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.1.0" +version = "0.2.3" dependencies = [ "dirs", "gtk4", @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9511358..354c58c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.3" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/README.md b/README.md index e44a46a..405c193 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ bakery install breadbar | Package | Description | |---------|-------------| -| `bread` | Reactive automation daemon (`breadd`) + CLI | -| `breadbar` | Status bar and notification daemon | -| `breadbox` | Cloud sync daemon (`breadbox-sync`) + file browser | -| `breadcrumbs` | Network information CLI | -| `breadpad` | Scratchpad / quick-note app | +| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events | +| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland | +| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) | +| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | +| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | ## Installing bakery @@ -49,13 +49,18 @@ bakery remove # 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` `librsvg` `dbus` | -| `breadcrumbs` | `networkmanager` | -| `breadpad` | `gtk4` `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 `. Use `pacman -Q ` 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///`: + +| File | Purpose | +|------|---------| +| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` | +| `-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 diff --git a/bakery.toml b/bakery.toml index 057558d..c88ee13 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,6 +2,7 @@ name = "bakery" description = "Bread ecosystem package manager" binaries = ["bakery"] system_deps = [] +optional_system_deps = [] bread_deps = [] [install] diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index b0724b3..df5b7c1 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -18,3 +18,6 @@ sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } chrono = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index f36fe45..0a7194b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -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> { - 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, + /// Optional deps that are not present — advisory only, never blocks. + pub warnings: Vec, } -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 { + 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,90 @@ 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()); + } + + // 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()); + } +} diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 8701470..c89744c 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -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()); + } +} diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 2562e4d..03fb65f 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -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") + if !unit_path.exists() { + eprintln!( + " warning: unit file {} not found — skipping service setup", + svc.unit + ); + return Ok(()); + } + + patch_exec_start(&unit_path, bin_dir)?; + + if !Command::new("systemctl") .args(["--user", "daemon-reload"]) - .status(); + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + eprintln!(" warning: systemctl daemon-reload failed"); + } if svc.enable { - if Command::new("systemctl") + 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() + .map(|s| s.success()) + .unwrap_or(false) + { + println!(" {} restarted", svc.unit); + } else { + eprintln!(" warning: failed to restart {}", svc.unit); + } + } else if Command::new("systemctl") + .args(["--user", "enable", "--now", &svc.unit]) + .status() + .map(|s| s.success()) .unwrap_or(false) { - let _ = Command::new("systemctl") - .args(["--user", "restart", &svc.unit]) - .status(); - println!(" {} restarted", svc.unit); - } else { - let _ = Command::new("systemctl") - .args(["--user", "enable", "--now", &svc.unit]) - .status(); 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::>() .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")); + } +} diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 1fec0bb..821f55a 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -6,10 +6,11 @@ mod state; use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; +use std::collections::HashSet; use std::path::PathBuf; #[derive(Parser)] -#[command(name = "bakery", about = "Package manager for the bread ecosystem")] +#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] struct Cli { #[command(subcommand)] command: Cmd, @@ -20,9 +21,10 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Install a package + /// Install one or more packages Install { - package: String, + #[arg(required = true, num_args = 1..)] + packages: Vec, }, /// Remove an installed package (data files are never deleted) Remove { @@ -30,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, + /// Update all installed packages + #[arg(long, conflicts_with = "package")] + all: bool, }, /// List packages List { @@ -61,27 +67,59 @@ fn main() -> Result<()> { let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); match cli.command { - Cmd::Install { package } => cmd_install(&package, &bin_dir), + Cmd::Install { packages } => { + 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::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()), } } -fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(false)?; +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, +) -> 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"); } @@ -92,15 +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 targets: Vec = match name { - Some(n) => vec![n.to_string()], - None => state.packages.keys().cloned().collect(), + let targets: Vec = 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, @@ -116,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 { - println!( - "updating {pkg_name} {} → {}", - installed.version, latest.version - ); - install::install_package(latest, bin_dir)?; + continue; } + + println!( + "updating {pkg_name} {} → {}", + installed.version, latest.version + ); + + 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(()) } @@ -173,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::>().join(", ")); + println!( + " binaries: {}", + pkg.binaries + .iter() + .map(|b| b.name.as_str()) + .collect::>() + .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::>().join(", ")); + println!( + " services: {}", + pkg.services + .iter() + .map(|s| s.unit.as_str()) + .collect::>() + .join(", ") + ); } Ok(()) } @@ -191,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { let state = state::State::load()?; let targets: Vec = 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(), }; @@ -203,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; } } diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5f27249..5106646 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -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, } @@ -36,6 +36,8 @@ pub struct Package { #[serde(default)] pub system_deps: Vec, #[serde(default)] + pub optional_system_deps: Vec, + #[serde(default)] pub bread_deps: Vec, #[serde(default)] pub services: Vec, @@ -44,6 +46,21 @@ pub struct Package { pub post_install: Vec, } +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 { 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> { let mut buf = Vec::new(); resp.into_reader() .read_to_end(&mut buf) - .context("reading binary")?; + .context("reading response")?; Ok(buf) } diff --git a/bakery/src/state.rs b/bakery/src/state.rs index adb39a4..92b9aa9 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -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"]); + } +} diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index fb641d8..530f938 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -7,6 +7,11 @@ description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland" homepage = "https://breadway.dev" dl_base = "https://dl.breadway.dev" +[[products]] +name = "bakery" +repo = "Breadway/bread-ecosystem" +description = "Bread ecosystem package manager" + [[products]] name = "bread" repo = "Breadway/bread" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh old mode 100644 new mode 100755 index f695f56..86d3f40 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,28 +1,28 @@ #!/usr/bin/env bash # Generate dl.breadway.dev/index.json from: # - registry/bread-ecosystem.toml (product list) -# - /bakery.toml (per-product metadata) -# - /srv/breadway-dl/ (built binaries + sha256 files) +# - //bakery.toml (per-product metadata, uploaded by release.yml) +# - / (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}")" @@ -51,11 +51,11 @@ build_package_json() { # Collect all binaries in the version dir (executables only; skip metadata files). local binaries_json="[]" for bin_path in "${version_dir}"/*; do - [[ "${bin_path}" == *.sha256 ]] && continue - [[ "${bin_path}" == *.toml ]] && continue + [[ "${bin_path}" == *.sha256 ]] && continue + [[ "${bin_path}" == *.toml ]] && continue [[ "${bin_path}" == *.service ]] && continue - [[ "${bin_path}" == *.css ]] && continue - [[ "${bin_path}" == *.txt ]] && continue + [[ "${bin_path}" == *.css ]] && continue + [[ "${bin_path}" == *.txt ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name bin_name="$(basename "${bin_path}")" @@ -77,45 +77,78 @@ 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. - local bakery_toml="${DL_DIR}/${name}/bakery.toml" + # Locate bakery.toml. The release workflow copies it into the version dir + # alongside the binaries (${version_dir}/bakery.toml). Fall back to a + # sibling repo checkout for local dev use. + local bakery_toml="${version_dir}/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 copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 + return 1 + fi - if [[ -f "${bakery_toml}" ]]; then - description="$(python3 -c " -import tomllib, sys + local description system_deps optional_system_deps bread_deps services config post_install + + description="$(python3 -c " +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 + + system_deps="$(python3 -c " +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 "[]")" - bread_deps="$(python3 -c " -import tomllib, json, sys + + 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 with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('bread_deps', []))) " 2>/dev/null || echo "[]")" - post_install="$(python3 -c " -import tomllib, json, sys + + # [[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 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 +156,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 +167,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 }' } diff --git a/scripts/get.sh b/scripts/get.sh old mode 100644 new mode 100755 index bdcf743..cc343f0 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -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 diff --git a/scripts/test-gen-index.sh b/scripts/test-gen-index.sh new file mode 100755 index 0000000..5a2733a --- /dev/null +++ b/scripts/test-gen-index.sh @@ -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}//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"