Init commit
This commit is contained in:
commit
6c5536733f
19 changed files with 3312 additions and 0 deletions
20
bakery/Cargo.toml
Normal file
20
bakery/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "bakery"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Package manager for the bread ecosystem"
|
||||
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
72
bakery/src/doctor.rs
Normal file
72
bakery/src/doctor.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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)
|
||||
}
|
||||
|
||||
fn dep_present(dep: &str) -> bool {
|
||||
// Try `which` first (covers executables like `iw`, `nmcli`).
|
||||
if which(dep) {
|
||||
return true;
|
||||
}
|
||||
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
|
||||
pkg_config_exists(dep)
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(bin)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.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)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.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() {
|
||||
println!(" {package_name}: no system deps required");
|
||||
return true;
|
||||
}
|
||||
match check_deps(deps) {
|
||||
Err(e) => {
|
||||
eprintln!(" error running doctor: {e}");
|
||||
false
|
||||
}
|
||||
Ok(missing) => {
|
||||
if missing.is_empty() {
|
||||
println!(" {package_name}: all system deps satisfied");
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" {package_name}: missing system deps: {}",
|
||||
missing.join(", ")
|
||||
);
|
||||
eprintln!(
|
||||
" install with: sudo pacman -S {}",
|
||||
missing.join(" ")
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
bakery/src/download.rs
Normal file
47
bakery/src/download.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::manifest::{fetch_binary, Binary};
|
||||
|
||||
/// Download a binary to a temp path, verify its SHA-256, then atomically move
|
||||
/// it into place. Bails before touching `dest` if the checksum fails.
|
||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
||||
println!(" downloading {}…", binary.name);
|
||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||
.with_context(|| format!("downloading {}", binary.name))?;
|
||||
|
||||
verify_sha256(&bytes, &binary.sha256)
|
||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||
|
||||
if let Some(dir) = dest.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
let tmp = dest.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes).context("writing binary to tmp")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp, dest).context("placing binary")?;
|
||||
println!(" installed {}", dest.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let actual = hex::encode(hasher.finalize());
|
||||
if actual != expected_hex {
|
||||
bail!(
|
||||
"SHA-256 mismatch\n expected: {}\n actual: {}",
|
||||
expected_hex,
|
||||
actual
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
253
bakery/src/install.rs
Normal file
253
bakery/src/install.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::download::fetch_and_place;
|
||||
use crate::manifest::{Package, Service};
|
||||
use crate::state::{InstalledPackage, State};
|
||||
|
||||
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
|
||||
println!("installing {}@{}…", pkg.name, pkg.version);
|
||||
|
||||
// 1. Download and verify all binaries.
|
||||
let mut binary_names = Vec::new();
|
||||
for bin in &pkg.binaries {
|
||||
let dest = bin_dir.join(&bin.name);
|
||||
fetch_and_place(bin, &dest)?;
|
||||
binary_names.push(bin.name.clone());
|
||||
}
|
||||
|
||||
// 2. Scaffold config dir + example file.
|
||||
if let Some(cfg) = &pkg.config {
|
||||
scaffold_config(cfg)?;
|
||||
}
|
||||
|
||||
// 3. Install systemd user units.
|
||||
let mut service_names = Vec::new();
|
||||
for svc in &pkg.services {
|
||||
install_service(svc, bin_dir)?;
|
||||
service_names.push(svc.unit.clone());
|
||||
}
|
||||
|
||||
// 4. Run post_install hooks.
|
||||
for cmd in &pkg.post_install {
|
||||
run_hook(cmd, &pkg.name)?;
|
||||
}
|
||||
|
||||
// 5. Record in state.
|
||||
let mut state = State::load()?;
|
||||
state.record(InstalledPackage {
|
||||
name: pkg.name.clone(),
|
||||
version: pkg.version.clone(),
|
||||
binaries: binary_names,
|
||||
services: service_names,
|
||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
state.save()?;
|
||||
|
||||
println!(" {} installed successfully", pkg.name);
|
||||
warn_path_if_needed(bin_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
||||
let mut state = State::load()?;
|
||||
let installed = match state.remove(pkg_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Remove binaries.
|
||||
for bin in &installed.binaries {
|
||||
let path = bin_dir.join(bin);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("removing {}", path.display()))?;
|
||||
println!(" removed {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for unit removal.
|
||||
if !installed.services.is_empty() {
|
||||
let service_dir = systemd_user_dir();
|
||||
for unit in &installed.services {
|
||||
let unit_path = service_dir.join(unit);
|
||||
if confirm_remove_unit(unit) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", unit])
|
||||
.status();
|
||||
if unit_path.exists() {
|
||||
std::fs::remove_file(&unit_path).ok();
|
||||
}
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
println!(" removed unit {unit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Never touch config or data dirs.
|
||||
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||
if cfg_dir.exists() {
|
||||
println!(" config preserved at {}", cfg_dir.display());
|
||||
}
|
||||
}
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join(pkg_name);
|
||||
if data_dir.exists() {
|
||||
println!(" data preserved at {}", data_dir.display());
|
||||
}
|
||||
|
||||
state.save()?;
|
||||
println!(" {pkg_name} removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> 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()
|
||||
);
|
||||
} else {
|
||||
println!(" config at {} already exists, skipping", dest.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_service(svc: &Service, bin_dir: &Path) -> 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)?;
|
||||
}
|
||||
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
|
||||
if svc.enable {
|
||||
if Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", &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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||
let text = std::fs::read_to_string(unit_path)?;
|
||||
let patched: String = text
|
||||
.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()) {
|
||||
let new_path = bin_dir.join(bin_name);
|
||||
let args: Vec<&str> = argv.iter().skip(1).copied().collect();
|
||||
if args.is_empty() {
|
||||
format!("ExecStart={}", new_path.display())
|
||||
} else {
|
||||
format!("ExecStart={} {}", new_path.display(), args.join(" "))
|
||||
}
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(unit_path, patched)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
||||
println!(" running post_install hook: {cmd}");
|
||||
let status = Command::new("sh")
|
||||
.args(["-c", cmd])
|
||||
.status()
|
||||
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
||||
if !status.success() {
|
||||
eprintln!(" warning: hook exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confirm_remove_unit(unit: &str) -> bool {
|
||||
use std::io::{self, Write};
|
||||
print!(" remove systemd unit {unit}? [y/N] ");
|
||||
io::stdout().flush().ok();
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf).ok();
|
||||
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
|
||||
}
|
||||
|
||||
fn systemd_user_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.join("systemd/user")
|
||||
}
|
||||
|
||||
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
|
||||
Some(dirs::config_dir()?.join(pkg_name))
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(rest)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_path_if_needed(bin_dir: &Path) {
|
||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||
let bin_str = bin_dir.to_string_lossy();
|
||||
if !path_var.split(':').any(|p| p == bin_str) {
|
||||
println!(
|
||||
"\n note: {} is not in PATH — add to your shell profile:",
|
||||
bin_str
|
||||
);
|
||||
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||
}
|
||||
}
|
||||
216
bakery/src/main.rs
Normal file
216
bakery/src/main.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
mod doctor;
|
||||
mod download;
|
||||
mod install;
|
||||
mod manifest;
|
||||
mod state;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bakery", about = "Package manager for the bread ecosystem")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
/// Override the directory where binaries are installed
|
||||
#[arg(long, env = "BAKERY_BIN_DIR", global = true)]
|
||||
bin_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Install a package
|
||||
Install {
|
||||
package: String,
|
||||
},
|
||||
/// Remove an installed package (data files are never deleted)
|
||||
Remove {
|
||||
package: String,
|
||||
},
|
||||
/// Update one or all installed packages
|
||||
Update {
|
||||
/// Package to update; omit to update all installed packages
|
||||
package: Option<String>,
|
||||
},
|
||||
/// List packages
|
||||
List {
|
||||
/// Show only installed packages
|
||||
#[arg(long)]
|
||||
installed: bool,
|
||||
},
|
||||
/// Show details for a package
|
||||
Info {
|
||||
package: String,
|
||||
},
|
||||
/// Check system dependencies for installed or requested packages
|
||||
Doctor {
|
||||
/// Package to check; omit to check all installed packages
|
||||
package: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_bin_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/bin")
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||
|
||||
match cli.command {
|
||||
Cmd::Install { package } => cmd_install(&package, &bin_dir),
|
||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
||||
Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
|
||||
Cmd::List { installed } => cmd_list(installed),
|
||||
Cmd::Info { package } => cmd_info(&package),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let index = manifest::load(false)?;
|
||||
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(" "));
|
||||
bail!("system deps not satisfied");
|
||||
}
|
||||
|
||||
install::install_package(pkg, bin_dir)
|
||||
}
|
||||
|
||||
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
|
||||
let state = state::State::load()?;
|
||||
|
||||
let targets: Vec<String> = match name {
|
||||
Some(n) => vec![n.to_string()],
|
||||
None => state.packages.keys().cloned().collect(),
|
||||
};
|
||||
|
||||
for pkg_name in &targets {
|
||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let latest = match index.get(pkg_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} not found in index, skipping");
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_list(installed_only: bool) -> Result<()> {
|
||||
let state = state::State::load()?;
|
||||
|
||||
if installed_only {
|
||||
if state.packages.is_empty() {
|
||||
println!("no packages installed");
|
||||
}
|
||||
for pkg in state.packages.values() {
|
||||
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let index = manifest::load(false)?;
|
||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
let pkg = &index.packages[name];
|
||||
let tag = if state.is_installed(name) {
|
||||
format!(" [installed {}]", state.packages[name].version)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_info(name: &str) -> Result<()> {
|
||||
let index = manifest::load(false)?;
|
||||
let pkg = index
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||
|
||||
let state = state::State::load()?;
|
||||
let status = if let Some(inst) = state.packages.get(name) {
|
||||
format!("installed ({})", inst.version)
|
||||
} else {
|
||||
"not installed".to_string()
|
||||
};
|
||||
|
||||
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(", "));
|
||||
if !pkg.system_deps.is_empty() {
|
||||
println!(" system deps: {}", pkg.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(", "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
||||
let index = manifest::load(false)?;
|
||||
let state = state::State::load()?;
|
||||
|
||||
let targets: Vec<String> = match name {
|
||||
Some(n) => vec![n.to_string()],
|
||||
None => state.packages.keys().cloned().collect(),
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("no packages installed — nothing to check");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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) {
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_ok {
|
||||
println!("all checks passed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
137
bakery/src/manifest.rs
Normal file
137
bakery/src/manifest.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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,
|
||||
/// relative to the product repo root; copied as-is if absent at install time
|
||||
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 bread_deps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub services: Vec<Service>,
|
||||
pub config: Option<ConfigScaffold>,
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
}
|
||||
|
||||
#[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 binary")?;
|
||||
Ok(buf)
|
||||
}
|
||||
60
bakery/src/state.rs
Normal file
60
bakery/src/state.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstalledPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub binaries: Vec<String>,
|
||||
pub services: Vec<String>,
|
||||
pub installed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
pub struct State {
|
||||
pub packages: HashMap<String, InstalledPackage>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = state_path();
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).context("reading installed.json")?;
|
||||
serde_json::from_str(&text).context("parsing installed.json")
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = state_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(&path, text).context("writing installed.json")
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, name: &str) -> bool {
|
||||
self.packages.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn record(&mut self, pkg: InstalledPackage) {
|
||||
self.packages.insert(pkg.name.clone(), pkg);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
|
||||
self.packages.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
dirs::state_dir()
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/state")
|
||||
})
|
||||
.join("bakery/installed.json")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue