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
153 lines
4.4 KiB
Rust
153 lines
4.4 KiB
Rust
use anyhow::{bail, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
|
|
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Binary {
|
|
pub name: String,
|
|
pub dl_url: String,
|
|
pub github_url: String,
|
|
pub sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Service {
|
|
pub unit: String,
|
|
pub enable: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ConfigScaffold {
|
|
pub dir: String,
|
|
/// Example config filename, relative to the release artifact directory.
|
|
pub example: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Package {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub version: String,
|
|
pub binaries: Vec<Binary>,
|
|
#[serde(default)]
|
|
pub system_deps: Vec<String>,
|
|
#[serde(default)]
|
|
pub optional_system_deps: Vec<String>,
|
|
#[serde(default)]
|
|
pub bread_deps: Vec<String>,
|
|
#[serde(default)]
|
|
pub services: Vec<Service>,
|
|
pub config: Option<ConfigScaffold>,
|
|
#[serde(default)]
|
|
pub post_install: Vec<String>,
|
|
}
|
|
|
|
impl Package {
|
|
/// Returns `(primary_url, github_url)` for any artifact filename in this
|
|
/// package's release directory. Derived by stripping the filename from the
|
|
/// first binary's URLs.
|
|
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
|
|
let first = self.binaries.first()?;
|
|
let dl_base = first.dl_url.rsplit_once('/')?.0;
|
|
let gh_base = first.github_url.rsplit_once('/')?.0;
|
|
Some((
|
|
format!("{dl_base}/{filename}"),
|
|
format!("{gh_base}/{filename}"),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Index {
|
|
pub version: String,
|
|
pub packages: std::collections::HashMap<String, Package>,
|
|
}
|
|
|
|
impl Index {
|
|
pub fn get(&self, name: &str) -> Option<&Package> {
|
|
self.packages.get(name)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn all(&self) -> impl Iterator<Item = &Package> {
|
|
self.packages.values()
|
|
}
|
|
}
|
|
|
|
/// Load the manifest, using the on-disk cache when it is fresh enough.
|
|
/// Always fetches if `force_refresh` is true.
|
|
pub fn load(force_refresh: bool) -> Result<Index> {
|
|
let cache_path = cache_path();
|
|
|
|
if !force_refresh && cache_is_fresh(&cache_path) {
|
|
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
|
|
return serde_json::from_str(&text).context("parsing cached index");
|
|
}
|
|
|
|
fetch_and_cache(&cache_path)
|
|
}
|
|
|
|
fn cache_is_fresh(path: &PathBuf) -> bool {
|
|
std::fs::metadata(path)
|
|
.and_then(|m| m.modified())
|
|
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
|
|
let text = fetch_text(PRIMARY_URL)?;
|
|
if let Some(dir) = cache_path.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
std::fs::write(cache_path, &text)?;
|
|
serde_json::from_str(&text).context("parsing index.json")
|
|
}
|
|
|
|
fn fetch_text(url: &str) -> Result<String> {
|
|
ureq::get(url)
|
|
.call()
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?
|
|
.into_string()
|
|
.context("reading response body")
|
|
}
|
|
|
|
pub fn cache_path() -> PathBuf {
|
|
dirs::cache_dir()
|
|
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
|
.join("bakery/index.json")
|
|
}
|
|
|
|
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
|
|
/// on any network error. Returns the raw bytes.
|
|
pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
|
match fetch_bytes(primary_url) {
|
|
Ok(bytes) => Ok(bytes),
|
|
Err(primary_err) => {
|
|
eprintln!(
|
|
" primary URL failed ({}), trying GitHub fallback…",
|
|
primary_err
|
|
);
|
|
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
|
use std::io::Read;
|
|
let resp = ureq::get(url)
|
|
.call()
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let status = resp.status();
|
|
if status != 200 {
|
|
bail!("HTTP {status} from {url}");
|
|
}
|
|
let mut buf = Vec::new();
|
|
resp.into_reader()
|
|
.read_to_end(&mut buf)
|
|
.context("reading response")?;
|
|
Ok(buf)
|
|
}
|