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
This commit is contained in:
parent
0b38e8cce3
commit
694829c50f
13 changed files with 971 additions and 148 deletions
|
|
@ -1,37 +1,45 @@
|
|||
use anyhow::Result;
|
||||
use std::process::Command;
|
||||
|
||||
/// Check whether a list of system dependencies are present.
|
||||
/// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
|
||||
pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
|
||||
let mut missing = Vec::new();
|
||||
for dep in deps {
|
||||
if !dep_present(dep) {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
pub struct DepReport {
|
||||
/// Required deps that are not present — blocks install.
|
||||
pub missing: Vec<String>,
|
||||
/// Optional deps that are not present — advisory only, never blocks.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn dep_present(dep: &str) -> bool {
|
||||
// Try `which` first (covers executables like `iw`, `nmcli`).
|
||||
if which(dep) {
|
||||
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||
Ok(DepReport {
|
||||
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn dep_present(pkg: &str) -> bool {
|
||||
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
|
||||
if pacman_installed(pkg) {
|
||||
return true;
|
||||
}
|
||||
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
|
||||
pkg_config_exists(dep)
|
||||
// Fallback for environments without pacman: native PATH search then pkg-config.
|
||||
path_has(pkg) || pkg_config_exists(pkg)
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(bin)
|
||||
fn pacman_installed(pkg: &str) -> bool {
|
||||
Command::new("pacman")
|
||||
.args(["-Q", pkg])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check PATH without shelling out to `which` (avoids the external dependency).
|
||||
fn path_has(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn pkg_config_exists(lib: &str) -> bool {
|
||||
// Arch package names map directly to pkg-config names for GTK libs.
|
||||
Command::new("pkg-config")
|
||||
.arg("--exists")
|
||||
.arg(lib)
|
||||
|
|
@ -40,33 +48,88 @@ fn pkg_config_exists(lib: &str) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Print a formatted doctor report for a list of system deps.
|
||||
/// Returns true if all deps are satisfied.
|
||||
pub fn report(package_name: &str, deps: &[String]) -> bool {
|
||||
if deps.is_empty() {
|
||||
/// Print a formatted doctor report for a package's system deps.
|
||||
/// Returns true if all *required* deps are satisfied.
|
||||
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||
if required.is_empty() && optional.is_empty() {
|
||||
println!(" {package_name}: no system deps required");
|
||||
return true;
|
||||
}
|
||||
match check_deps(deps) {
|
||||
match check_deps(required, optional) {
|
||||
Err(e) => {
|
||||
eprintln!(" error running doctor: {e}");
|
||||
eprintln!(" error running doctor for {package_name}: {e}");
|
||||
false
|
||||
}
|
||||
Ok(missing) => {
|
||||
if missing.is_empty() {
|
||||
println!(" {package_name}: all system deps satisfied");
|
||||
Ok(rep) => {
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(
|
||||
" {package_name}: optional dep not found: {warn} \
|
||||
(install for full functionality)"
|
||||
);
|
||||
}
|
||||
if rep.missing.is_empty() {
|
||||
println!(" {package_name}: all required system deps satisfied");
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" {package_name}: missing system deps: {}",
|
||||
missing.join(", ")
|
||||
);
|
||||
eprintln!(
|
||||
" install with: sudo pacman -S {}",
|
||||
missing.join(" ")
|
||||
rep.missing.join(", ")
|
||||
);
|
||||
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_deps_pass() {
|
||||
let rep = check_deps(&[], &[]).unwrap();
|
||||
assert!(rep.missing.is_empty());
|
||||
assert!(rep.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_finds_itself() {
|
||||
// pacman is always installed on Arch — verifies our detection path works.
|
||||
assert!(pacman_installed("pacman"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_has_finds_sh() {
|
||||
assert!(path_has("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_dep_detected() {
|
||||
let rep = check_deps(
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rep.missing.len(), 1);
|
||||
assert!(rep.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_dep_becomes_warning_not_error() {
|
||||
let rep = check_deps(
|
||||
&[],
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rep.missing.is_empty());
|
||||
assert_eq!(rep.warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_dep_not_missing() {
|
||||
// "pacman" is definitely installed; should not appear in missing.
|
||||
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
|
||||
assert!(rep.missing.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(unit_path, patched)?;
|
||||
// Preserve trailing newline if the original had one.
|
||||
let output = if text.ends_with('\n') {
|
||||
format!("{patched}\n")
|
||||
} else {
|
||||
patched
|
||||
};
|
||||
std::fs::write(unit_path, output)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +293,7 @@ fn expand_tilde(path: &str) -> PathBuf {
|
|||
}
|
||||
}
|
||||
|
||||
fn strip_arch_suffix(name: &str) -> &str {
|
||||
pub fn strip_arch_suffix(name: &str) -> &str {
|
||||
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
|
||||
for s in SUFFIXES {
|
||||
if let Some(base) = name.strip_suffix(s) {
|
||||
|
|
@ -262,3 +314,53 @@ fn warn_path_if_needed(bin_dir: &Path) {
|
|||
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn strip_known_suffixes() {
|
||||
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
|
||||
assert_eq!(strip_arch_suffix("breadd-aarch64"), "breadd");
|
||||
assert_eq!(strip_arch_suffix("breadd-arm64"), "breadd");
|
||||
assert_eq!(strip_arch_suffix("breadd-armv7"), "breadd");
|
||||
assert_eq!(strip_arch_suffix("bakery-x86_64"), "bakery");
|
||||
assert_eq!(strip_arch_suffix("breadd"), "breadd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_exec_start_with_args() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("test.service");
|
||||
fs::write(&path, "[Service]\nExecStart=/old/path/bin arg1 arg2\n").unwrap();
|
||||
patch_exec_start(&path, Path::new("/new/bin")).unwrap();
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
assert!(out.contains("ExecStart=/new/bin/bin arg1 arg2"));
|
||||
assert!(out.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_exec_start_no_args() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("test.service");
|
||||
fs::write(&path, "[Service]\nExecStart=/old/path/daemon\n").unwrap();
|
||||
patch_exec_start(&path, Path::new("/usr/local/bin")).unwrap();
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
assert!(out.contains("ExecStart=/usr/local/bin/daemon"));
|
||||
assert!(!out.contains("daemon "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_exec_start_non_exec_lines_unchanged() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("test.service");
|
||||
fs::write(&path, "[Unit]\nDescription=foo\nExecStart=/bin/foo\n").unwrap();
|
||||
patch_exec_start(&path, Path::new("/usr/bin")).unwrap();
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
assert!(out.contains("Description=foo"));
|
||||
assert!(out.contains("ExecStart=/usr/bin/foo"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod state;
|
|||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -31,8 +32,12 @@ enum Cmd {
|
|||
},
|
||||
/// Update one or all installed packages
|
||||
Update {
|
||||
/// Package to update; omit to update all installed packages
|
||||
/// Package to update (omit or use --all to update everything installed)
|
||||
#[arg(conflicts_with = "all")]
|
||||
package: Option<String>,
|
||||
/// Update all installed packages
|
||||
#[arg(long, conflicts_with = "package")]
|
||||
all: bool,
|
||||
},
|
||||
/// List packages
|
||||
List {
|
||||
|
|
@ -70,7 +75,7 @@ fn main() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
||||
Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
|
||||
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir),
|
||||
Cmd::List { installed } => cmd_list(installed),
|
||||
Cmd::Info { package } => cmd_info(&package),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
||||
|
|
@ -78,16 +83,43 @@ fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let mut visited = HashSet::new();
|
||||
install_with_deps(index, name, bin_dir, &mut visited)
|
||||
}
|
||||
|
||||
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
||||
/// packages. The `visited` set prevents cycles.
|
||||
fn install_with_deps(
|
||||
index: &manifest::Index,
|
||||
name: &str,
|
||||
bin_dir: &std::path::Path,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
if !visited.insert(name.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pkg = index
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||
|
||||
// Doctor runs first — bail if system deps are missing.
|
||||
println!("checking system dependencies…");
|
||||
let missing = doctor::check_deps(&pkg.system_deps)?;
|
||||
if !missing.is_empty() {
|
||||
eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
|
||||
eprintln!("install with: sudo pacman -S {}", missing.join(" "));
|
||||
// Install bread_deps first (skip those already recorded in state).
|
||||
let state = state::State::load()?;
|
||||
for dep in pkg.bread_deps.clone() {
|
||||
if !state.is_installed(&dep) {
|
||||
println!("installing bread dependency: {dep}");
|
||||
install_with_deps(index, &dep, bin_dir, visited)?;
|
||||
}
|
||||
}
|
||||
|
||||
println!("checking system dependencies for {name}…");
|
||||
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(" note: optional dep not installed: {warn}");
|
||||
}
|
||||
if !rep.missing.is_empty() {
|
||||
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
|
||||
eprintln!("install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||
bail!("system deps not satisfied");
|
||||
}
|
||||
|
||||
|
|
@ -98,16 +130,22 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
|||
install::remove_package(name, bin_dir)
|
||||
}
|
||||
|
||||
fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let index = manifest::load(true)?; // force refresh on update
|
||||
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let index = manifest::load(true)?;
|
||||
let state = state::State::load()?;
|
||||
|
||||
let effective = name.filter(|&n| n != "all");
|
||||
let targets: Vec<String> = match effective {
|
||||
Some(n) => vec![n.to_string()],
|
||||
None => state.packages.keys().cloned().collect(),
|
||||
let targets: Vec<String> = if all || name.is_none() {
|
||||
state.packages.keys().cloned().collect()
|
||||
} else {
|
||||
vec![name.unwrap().to_string()]
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("no packages installed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut any_failed = false;
|
||||
for pkg_name in &targets {
|
||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||
Some(p) => p,
|
||||
|
|
@ -123,15 +161,45 @@ fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if installed.version == latest.version {
|
||||
println!("{pkg_name} is already at {}", installed.version);
|
||||
} else {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -180,15 +248,32 @@ fn cmd_info(name: &str) -> Result<()> {
|
|||
println!("{} {}", pkg.name, pkg.version);
|
||||
println!(" {}", pkg.description);
|
||||
println!(" status: {status}");
|
||||
println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
|
||||
println!(
|
||||
" binaries: {}",
|
||||
pkg.binaries
|
||||
.iter()
|
||||
.map(|b| b.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if !pkg.system_deps.is_empty() {
|
||||
println!(" system deps: {}", pkg.system_deps.join(", "));
|
||||
}
|
||||
if !pkg.optional_system_deps.is_empty() {
|
||||
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
|
||||
}
|
||||
if !pkg.bread_deps.is_empty() {
|
||||
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
||||
}
|
||||
if !pkg.services.is_empty() {
|
||||
println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
|
||||
println!(
|
||||
" services: {}",
|
||||
pkg.services
|
||||
.iter()
|
||||
.map(|s| s.unit.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -198,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
|||
let state = state::State::load()?;
|
||||
|
||||
let targets: Vec<String> = match name {
|
||||
Some(n) => vec![n.to_string()],
|
||||
Some(n) => {
|
||||
if index.get(n).is_none() {
|
||||
bail!("unknown package: {n}");
|
||||
}
|
||||
vec![n.to_string()]
|
||||
}
|
||||
None => state.packages.keys().cloned().collect(),
|
||||
};
|
||||
|
||||
|
|
@ -210,9 +300,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
|||
let mut all_ok = true;
|
||||
for pkg_name in &targets {
|
||||
if let Some(pkg) = index.get(pkg_name) {
|
||||
if !doctor::report(pkg_name, &pkg.system_deps) {
|
||||
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
|
||||
all_ok = false;
|
||||
}
|
||||
} else {
|
||||
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ pub struct Service {
|
|||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ConfigScaffold {
|
||||
pub dir: String,
|
||||
/// relative to the product repo root; copied as-is if absent at install time
|
||||
/// Example config filename, relative to the release artifact directory.
|
||||
pub example: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ pub struct Package {
|
|||
#[serde(default)]
|
||||
pub system_deps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub optional_system_deps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub bread_deps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub services: Vec<Service>,
|
||||
|
|
@ -44,6 +46,21 @@ pub struct Package {
|
|||
pub post_install: Vec<String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Returns `(primary_url, github_url)` for any artifact filename in this
|
||||
/// package's release directory. Derived by stripping the filename from the
|
||||
/// first binary's URLs.
|
||||
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
|
||||
let first = self.binaries.first()?;
|
||||
let dl_base = first.dl_url.rsplit_once('/')?.0;
|
||||
let gh_base = first.github_url.rsplit_once('/')?.0;
|
||||
Some((
|
||||
format!("{dl_base}/{filename}"),
|
||||
format!("{gh_base}/{filename}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Index {
|
||||
pub version: String,
|
||||
|
|
@ -67,8 +84,7 @@ pub fn load(force_refresh: bool) -> Result<Index> {
|
|||
let cache_path = cache_path();
|
||||
|
||||
if !force_refresh && cache_is_fresh(&cache_path) {
|
||||
let text = std::fs::read_to_string(&cache_path)
|
||||
.context("reading cached index")?;
|
||||
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
|
||||
return serde_json::from_str(&text).context("parsing cached index");
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +148,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
|||
let mut buf = Vec::new();
|
||||
resp.into_reader()
|
||||
.read_to_end(&mut buf)
|
||||
.context("reading binary")?;
|
||||
.context("reading response")?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue