Compare commits

...

10 commits

Author SHA1 Message Date
Breadway
85a1a867ce fix: look for bakery.toml in version_dir, not top-level package dir 2026-06-11 14:42:40 +08:00
Breadway
cbe66b92e5 fix: ignore pacman-dependent tests on non-Arch CI environments
Some checks failed
release / build (push) Failing after 36s
2026-06-11 14:27:43 +08:00
Breadway
c120ed8af0 chore: bump version to 0.2.3 2026-06-11 14:20:44 +08:00
Breadway
a8be86be03 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>
2026-06-11 13:37:09 +08:00
Breadway
a4ea036a7c fix: force index refresh on install, fetch once for multi-package installs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:23:54 +08:00
Breadway
7a17fcaa93 chore: bump version to 0.2.2
Some checks failed
release / build (push) Failing after 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:16:04 +08:00
Breadway
a9175aa4ef feat: add --version flag to bakery CLI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:13:34 +08:00
Breadway
f7957301d5 feat: register bakery as an installable package, bump to 0.2.1
Some checks failed
release / build (push) Failing after 36s
bakery can now update itself via `bakery update all`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:58:09 +08:00
Breadway
b97882e715 feat: multi-package install and bakery update all
Some checks failed
release / build (push) Failing after 32s
- bakery install now accepts one or more package names
- bakery update all treated as update-everything (same as bare update)
- bump version to 0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:51:20 +08:00
Breadway
5edb5bae31 docs: fix product descriptions and system deps in README
- breadbox: was 'cloud sync daemon + file browser'; it's actually a GTK4
  fuzzy app launcher; breadbox-sync resolves icons, not cloud data
- breadcrumbs: was 'network information CLI'; it's a profile-aware Wi-Fi
  state machine with Tailscale integration and a watch daemon
- breadpad: was 'scratchpad / quick-note app'; call out AI classification,
  reminders, recurrence, and the breadman viewer
- system deps: add gtk4-layer-shell for breadbox and breadpad (both use
  layer-shell windows); drop spurious 'dbus' from breadbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:28:39 +08:00
15 changed files with 1000 additions and 162 deletions

269
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,12 @@ impl State {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
let text = serde_json::to_string_pretty(self)?; let text = serde_json::to_string_pretty(self)?;
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 { pub fn is_installed(&self, name: &str) -> bool {
@ -58,3 +63,58 @@ fn state_path() -> PathBuf {
}) })
.join("bakery/installed.json") .join("bakery/installed.json")
} }
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn record_and_is_installed() {
let mut state = State::default();
assert!(!state.is_installed("foo"));
state.record(pkg("foo", "1.0.0"));
assert!(state.is_installed("foo"));
}
#[test]
fn remove_installed() {
let mut state = State::default();
state.record(pkg("foo", "1.0.0"));
let removed = state.remove("foo");
assert!(removed.is_some());
assert!(!state.is_installed("foo"));
}
#[test]
fn remove_unknown_returns_none() {
let mut state = State::default();
assert!(state.remove("nope").is_none());
}
#[test]
fn json_roundtrip() {
let mut state = State::default();
state.record(InstalledPackage {
name: "bar".to_string(),
version: "2.0.0".to_string(),
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
}
}

View file

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

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

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

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

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

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"