Catalog views use aligned columns and two-line entries so long -dev versions no longer smash the old 10-char pad. Install/update/remove get action banners and a verb column; downloads >= 256 KB show a real progress bar; clap help matches the same palette. NO_COLOR and non-TTY still strip color.
1189 lines
38 KiB
Rust
1189 lines
38 KiB
Rust
mod doctor;
|
|
mod download;
|
|
mod install;
|
|
mod manifest;
|
|
mod state;
|
|
mod track;
|
|
mod ui;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use clap::{CommandFactory, Parser, Subcommand};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use track::Track;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "bakery",
|
|
about = "Package manager for the bread ecosystem",
|
|
version,
|
|
styles = ui::CLAP_STYLES
|
|
)]
|
|
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>,
|
|
/// Skip post_install hooks entirely
|
|
#[arg(long, global = true)]
|
|
no_hooks: bool,
|
|
/// Assume yes to interactive prompts
|
|
#[arg(short = 'y', long = "yes", global = true)]
|
|
yes: bool,
|
|
/// Show what would happen without downloading, writing, or touching state
|
|
#[arg(long, global = true)]
|
|
dry_run: bool,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// Install one or more packages
|
|
Install {
|
|
#[arg(required = true, num_args = 1..)]
|
|
packages: Vec<String>,
|
|
},
|
|
/// Remove an installed package (config is never deleted)
|
|
Remove {
|
|
package: String,
|
|
/// Also remove the license file, desktop entry, and data dir
|
|
/// (~/.local/share/<pkg>/) — config is still preserved
|
|
#[arg(long)]
|
|
purge: bool,
|
|
},
|
|
/// Update one or all installed packages
|
|
Update {
|
|
/// 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 {
|
|
/// Show only installed packages
|
|
#[arg(long)]
|
|
installed: bool,
|
|
},
|
|
/// Show details for a package
|
|
Info { package: String },
|
|
/// Search package names and descriptions
|
|
Search { query: String },
|
|
/// Check system dependencies for installed or requested packages
|
|
Doctor {
|
|
/// Package to check; omit to check all installed packages
|
|
package: Option<String>,
|
|
},
|
|
/// Verify installed binaries against the checksum recorded at install time
|
|
Verify {
|
|
/// Package to verify; omit to verify all installed packages
|
|
package: Option<String>,
|
|
},
|
|
/// Roll back a package to its previously installed version, from a
|
|
/// local pre-update backup (not a re-download)
|
|
Rollback { package: String },
|
|
/// Update bakery itself
|
|
SelfUpdate,
|
|
/// Generate a shell completion script
|
|
Completions { shell: clap_complete::Shell },
|
|
/// View or switch which build track bakery follows (stable/beta/dev)
|
|
Track {
|
|
#[command(subcommand)]
|
|
action: TrackCmd,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum TrackCmd {
|
|
/// Show the currently selected track
|
|
Show,
|
|
/// Switch tracks. Only changes the preference — run `bakery update --all`
|
|
/// afterwards to actually install builds from the new track.
|
|
Set { track: Track },
|
|
}
|
|
|
|
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);
|
|
let no_hooks = cli.no_hooks;
|
|
let assume_yes = cli.yes;
|
|
let dry_run = cli.dry_run;
|
|
let track = state::State::load()?.track;
|
|
|
|
match cli.command {
|
|
Cmd::Install { packages } => {
|
|
let index = manifest::load(true, track)?;
|
|
for pkg in &packages {
|
|
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
|
Cmd::Update { package, all } => cmd_update(
|
|
package.as_deref(),
|
|
all,
|
|
&bin_dir,
|
|
track,
|
|
no_hooks,
|
|
assume_yes,
|
|
dry_run,
|
|
),
|
|
Cmd::List { installed } => cmd_list(installed, track),
|
|
Cmd::Info { package } => cmd_info(&package, track),
|
|
Cmd::Search { query } => cmd_search(&query, track),
|
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
|
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
|
|
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
|
|
// Same update logic as `bakery update bakery` — this is just a
|
|
// documented, discoverable entry point for it, since overwriting
|
|
// bakery's own running binary via a normal update already works
|
|
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
|
// first-class command.
|
|
Cmd::SelfUpdate => cmd_update(
|
|
Some("bakery"),
|
|
false,
|
|
&bin_dir,
|
|
track,
|
|
no_hooks,
|
|
assume_yes,
|
|
dry_run,
|
|
),
|
|
Cmd::Completions { shell } => cmd_completions(shell),
|
|
Cmd::Track { action } => cmd_track(action),
|
|
}
|
|
}
|
|
|
|
fn cmd_completions(shell: clap_complete::Shell) -> Result<()> {
|
|
clap_complete::generate(shell, &mut Cli::command(), "bakery", &mut std::io::stdout());
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_track(action: TrackCmd) -> Result<()> {
|
|
let state = state::State::load()?;
|
|
match action {
|
|
TrackCmd::Show => {
|
|
ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]);
|
|
}
|
|
TrackCmd::Set { track } => {
|
|
if state.track == track {
|
|
println!("{}", ui::unchanged(&format!("already on track {track}")));
|
|
return Ok(());
|
|
}
|
|
// Fail fast on a bad/unreachable track rather than silently
|
|
// recording a preference bakery can't actually serve.
|
|
manifest::load(true, track)
|
|
.with_context(|| format!("could not validate {track} track, not switching"))?;
|
|
state::State::with_lock(|state| {
|
|
state.set_track(track);
|
|
Ok(())
|
|
})?;
|
|
ui::action("Switched", track.as_str(), None);
|
|
ui::step(
|
|
"next",
|
|
&format!("bakery update --all to install {track} builds"),
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn cmd_install(
|
|
index: &manifest::Index,
|
|
name: &str,
|
|
bin_dir: &std::path::Path,
|
|
track: Track,
|
|
no_hooks: bool,
|
|
assume_yes: bool,
|
|
dry_run: bool,
|
|
) -> Result<()> {
|
|
let mut visited = HashSet::new();
|
|
install_with_deps(
|
|
index,
|
|
name,
|
|
bin_dir,
|
|
track,
|
|
no_hooks,
|
|
assume_yes,
|
|
dry_run,
|
|
&mut visited,
|
|
)
|
|
}
|
|
|
|
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
|
/// packages. The `visited` set prevents cycles.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn install_with_deps(
|
|
index: &manifest::Index,
|
|
name: &str,
|
|
bin_dir: &std::path::Path,
|
|
track: Track,
|
|
no_hooks: bool,
|
|
assume_yes: bool,
|
|
dry_run: bool,
|
|
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}"))?;
|
|
|
|
// 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) {
|
|
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
|
|
install_with_deps(
|
|
index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
let previous = state.packages.get(name);
|
|
|
|
// Already installed and not older than the index — nothing to do. This
|
|
// doubles as the implicit upgrade path when the index has something
|
|
// newer, so `bakery install <pkg>` is safe to run repeatedly instead of
|
|
// silently reinstalling (and potentially downgrading) every time.
|
|
if let Some(installed) = previous {
|
|
if !is_newer(&installed.version, &pkg.version) {
|
|
println!(
|
|
" {}",
|
|
ui::unchanged(&format!(
|
|
"{name} already at {} (index has {})",
|
|
installed.version, pkg.version
|
|
))
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
ui::action(
|
|
if dry_run {
|
|
if previous.is_some() {
|
|
"Would update"
|
|
} else {
|
|
"Would install"
|
|
}
|
|
} else if previous.is_some() {
|
|
"Updating"
|
|
} else {
|
|
"Installing"
|
|
},
|
|
name,
|
|
Some(&pkg.version),
|
|
);
|
|
ui::step("checking", "system dependencies");
|
|
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
|
|
for warn in &rep.warnings {
|
|
eprintln!(
|
|
" {}",
|
|
ui::note(&format!("optional dep not installed: {warn}"))
|
|
);
|
|
}
|
|
if !rep.missing.is_empty() {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!(
|
|
"missing system deps for {name}: {}",
|
|
rep.missing.join(", ")
|
|
))
|
|
);
|
|
eprintln!(
|
|
" {}",
|
|
ui::dim(&format!(
|
|
"install with: {}",
|
|
doctor::install_hint(&rep.missing)
|
|
))
|
|
);
|
|
bail!("system deps not satisfied");
|
|
}
|
|
|
|
if dry_run {
|
|
print_dry_run_plan(pkg);
|
|
return Ok(());
|
|
}
|
|
|
|
install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
|
|
}
|
|
|
|
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
|
|
/// `--dry-run`, once the version-comparison decision to actually act has
|
|
/// already been made — this only renders that decision, it never
|
|
/// recomputes it, so dry-run and real runs can't drift apart on "would this
|
|
/// update happen at all".
|
|
fn print_dry_run_plan(pkg: &manifest::Package) {
|
|
ui::kv(
|
|
"binaries",
|
|
&pkg.binaries
|
|
.iter()
|
|
.map(|b| b.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
if !pkg.services.is_empty() {
|
|
ui::kv(
|
|
"services",
|
|
&pkg.services
|
|
.iter()
|
|
.map(|s| s.unit.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
|
|
install::remove_package(name, bin_dir, assume_yes, purge)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn cmd_update(
|
|
name: Option<&str>,
|
|
all: bool,
|
|
bin_dir: &std::path::Path,
|
|
track: Track,
|
|
no_hooks: bool,
|
|
assume_yes: bool,
|
|
dry_run: bool,
|
|
) -> Result<()> {
|
|
let index = manifest::load(true, track)?;
|
|
let state = state::State::load()?;
|
|
|
|
let targets: Vec<String> = match name {
|
|
Some(n) if !all => vec![n.to_string()],
|
|
_ => state.packages.keys().cloned().collect(),
|
|
};
|
|
|
|
if targets.is_empty() {
|
|
println!("{}", ui::dim("no packages installed"));
|
|
return Ok(());
|
|
}
|
|
|
|
let mut targets = targets;
|
|
targets.sort();
|
|
if all {
|
|
ui::heading("Update", &[&format!("{} packages", targets.len())]);
|
|
}
|
|
|
|
let mut any_failed = false;
|
|
let mut updated = 0u32;
|
|
let mut unchanged = 0u32;
|
|
for pkg_name in &targets {
|
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!("{pkg_name} is not installed, skipping"))
|
|
);
|
|
any_failed = true;
|
|
continue;
|
|
}
|
|
};
|
|
let latest = match index.get(pkg_name) {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!("{pkg_name} not found in index, skipping"))
|
|
);
|
|
any_failed = true;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// A track switch is an explicit user action ("bakery track set beta
|
|
// && bakery update --all") and must always take effect, even if the
|
|
// new track's current build happens to be same-or-lower by strict
|
|
// semver than what's installed (e.g. switching stable -> dev, or a
|
|
// beta RC that shares a base version with the installed stable).
|
|
let track_switch = installed.track != track;
|
|
if !should_update(&installed.version, installed.track, track, &latest.version) {
|
|
// DIM, not GREEN — this is the steady-state common case (most
|
|
// packages, most runs), and reusing GREEN here drowns out the
|
|
// packages that actually changed below. The glyph itself
|
|
// (rather than just color) keeps that distinction legible even
|
|
// under a terminal palette that maps ANSI colors unusually.
|
|
println!(
|
|
" {}",
|
|
ui::unchanged(&format!("{pkg_name} already at {}", installed.version))
|
|
);
|
|
unchanged += 1;
|
|
continue;
|
|
}
|
|
|
|
ui::action(
|
|
if dry_run { "Would update" } else { "Updating" },
|
|
pkg_name,
|
|
Some(&latest.version),
|
|
);
|
|
if track_switch {
|
|
ui::step(
|
|
"track",
|
|
&format!(
|
|
"{} {} {}",
|
|
ui::dim(installed.track.as_str()),
|
|
ui::style("→", ui::CYAN),
|
|
ui::bold(track.as_str()),
|
|
),
|
|
);
|
|
} else {
|
|
ui::step(
|
|
"version",
|
|
&format!(
|
|
"{} {} {}",
|
|
ui::dim(&installed.version),
|
|
ui::style("→", ui::CYAN),
|
|
ui::bold(&latest.version)
|
|
),
|
|
);
|
|
}
|
|
|
|
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!("doctor check failed for {pkg_name}: {e}"))
|
|
);
|
|
any_failed = true;
|
|
continue;
|
|
}
|
|
};
|
|
for warn in &rep.warnings {
|
|
eprintln!(
|
|
" {}",
|
|
ui::note(&format!("optional dep not installed: {warn}"))
|
|
);
|
|
}
|
|
if !rep.missing.is_empty() {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!(
|
|
"missing deps for {pkg_name}: {} — skipping update",
|
|
rep.missing.join(", ")
|
|
))
|
|
);
|
|
any_failed = true;
|
|
continue;
|
|
}
|
|
|
|
if dry_run {
|
|
print_dry_run_plan(latest);
|
|
updated += 1;
|
|
continue;
|
|
}
|
|
|
|
if let Err(e) = install::install_package(
|
|
latest,
|
|
bin_dir,
|
|
track,
|
|
Some(installed),
|
|
no_hooks,
|
|
assume_yes,
|
|
) {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
|
);
|
|
any_failed = true;
|
|
} else {
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
// Only for --all: a single named update already makes its own outcome
|
|
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
|
|
if all {
|
|
let updated_s = format!("{updated} updated");
|
|
let unchanged_s = format!("{unchanged} already current");
|
|
let mut parts: Vec<&str> = Vec::new();
|
|
if updated > 0 {
|
|
parts.push(&updated_s);
|
|
}
|
|
parts.push(&unchanged_s);
|
|
ui::summary(&parts);
|
|
}
|
|
|
|
if any_failed {
|
|
bail!("one or more packages could not be updated");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether `pkg_name` should be updated: always true on a track switch
|
|
/// (an explicit user action that must take effect regardless of version
|
|
/// ordering), otherwise a real semver comparison via [`is_newer`].
|
|
fn should_update(
|
|
installed_version: &str,
|
|
installed_track: Track,
|
|
active_track: Track,
|
|
latest_version: &str,
|
|
) -> bool {
|
|
if installed_track != active_track {
|
|
return true;
|
|
}
|
|
is_newer(installed_version, latest_version)
|
|
}
|
|
|
|
/// Is `latest` newer than `installed`? Real semver comparison — the
|
|
/// previous plain string-equality check couldn't tell "different" from
|
|
/// "actually newer", so it would happily "update" a package to a lexically
|
|
/// different but not-newer version. Falls back to a simple inequality check
|
|
/// (with a warning) for any version string that isn't valid semver, rather
|
|
/// than hard-erroring on packages built before this convention existed.
|
|
fn is_newer(installed: &str, latest: &str) -> bool {
|
|
match (
|
|
semver::Version::parse(installed),
|
|
semver::Version::parse(latest),
|
|
) {
|
|
(Ok(i), Ok(l)) => l > i,
|
|
_ => {
|
|
if installed != latest {
|
|
eprintln!(
|
|
" warning: cannot determine if '{latest}' is newer than '{installed}' \
|
|
(not valid semver) — proceeding on inequality alone, this could be a downgrade"
|
|
);
|
|
}
|
|
installed != latest
|
|
}
|
|
}
|
|
}
|
|
|
|
fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow {
|
|
ui::CatalogRow {
|
|
name: pkg.name.clone(),
|
|
version: pkg.version.clone(),
|
|
installed: state.is_installed(&pkg.name),
|
|
detail: pkg.description.clone(),
|
|
aside: String::new(),
|
|
}
|
|
}
|
|
|
|
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
|
let state = state::State::load()?;
|
|
|
|
if installed_only {
|
|
if state.packages.is_empty() {
|
|
println!("{}", ui::dim("no packages installed"));
|
|
return Ok(());
|
|
}
|
|
let mut pkgs: Vec<_> = state.packages.values().collect();
|
|
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
|
|
ui::heading("Installed", &[&pkgs.len().to_string()]);
|
|
let rows: Vec<ui::CatalogRow> = pkgs
|
|
.iter()
|
|
.map(|pkg| ui::CatalogRow {
|
|
name: pkg.name.clone(),
|
|
version: pkg.version.clone(),
|
|
installed: true,
|
|
detail: String::new(),
|
|
aside: ui::short_date(&pkg.installed_at),
|
|
})
|
|
.collect();
|
|
ui::print_catalog(&rows);
|
|
return Ok(());
|
|
}
|
|
|
|
let index = manifest::load(false, track)?;
|
|
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
|
names.sort();
|
|
let installed = names.iter().filter(|n| state.is_installed(n)).count();
|
|
let track_part = ui::track_tag(track);
|
|
ui::heading(
|
|
"Packages",
|
|
&[
|
|
&format!("{} in index", names.len()),
|
|
&format!("{installed} installed"),
|
|
&track_part,
|
|
],
|
|
);
|
|
let rows: Vec<ui::CatalogRow> = names
|
|
.iter()
|
|
.map(|name| catalog_row(&index.packages[*name], &state))
|
|
.collect();
|
|
ui::print_catalog(&rows);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case-insensitive substring match against a package's name or description
|
|
/// — split out from `cmd_search` so the matching rule itself is testable
|
|
/// without a real index load.
|
|
fn matches_search(name: &str, description: &str, needle_lower: &str) -> bool {
|
|
name.to_lowercase().contains(needle_lower) || description.to_lowercase().contains(needle_lower)
|
|
}
|
|
|
|
fn cmd_search(query: &str, track: Track) -> Result<()> {
|
|
let state = state::State::load()?;
|
|
let index = manifest::load(false, track)?;
|
|
let needle = query.to_lowercase();
|
|
|
|
let mut names: Vec<&str> = index
|
|
.packages
|
|
.values()
|
|
.filter(|pkg| matches_search(&pkg.name, &pkg.description, &needle))
|
|
.map(|pkg| pkg.name.as_str())
|
|
.collect();
|
|
names.sort();
|
|
|
|
if names.is_empty() {
|
|
println!("{}", ui::dim(&format!("no packages matched '{query}'")));
|
|
return Ok(());
|
|
}
|
|
|
|
ui::heading(
|
|
"Search",
|
|
&[&format!("'{query}'"), &format!("{} matches", names.len())],
|
|
);
|
|
let rows: Vec<ui::CatalogRow> = names
|
|
.iter()
|
|
.map(|name| catalog_row(&index.packages[*name], &state))
|
|
.collect();
|
|
ui::print_catalog(&rows);
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_info(name: &str, track: Track) -> Result<()> {
|
|
let index = manifest::load(false, track)?;
|
|
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) {
|
|
ui::style(&format!("installed {}", inst.version), ui::GREEN)
|
|
} else {
|
|
ui::style("not installed", ui::DIM)
|
|
};
|
|
|
|
let track_part = ui::track_tag(track);
|
|
ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]);
|
|
println!(" {}\n", pkg.description);
|
|
ui::kv("status", &status);
|
|
ui::kv(
|
|
"binaries",
|
|
&pkg.binaries
|
|
.iter()
|
|
.map(|b| b.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
if !pkg.system_deps.is_empty() {
|
|
ui::kv("system deps", &pkg.system_deps.join(", "));
|
|
}
|
|
if !pkg.optional_system_deps.is_empty() {
|
|
ui::kv("optional", &pkg.optional_system_deps.join(", "));
|
|
}
|
|
if !pkg.bread_deps.is_empty() {
|
|
ui::kv("bread deps", &pkg.bread_deps.join(", "));
|
|
}
|
|
if !pkg.services.is_empty() {
|
|
ui::kv(
|
|
"services",
|
|
&pkg.services
|
|
.iter()
|
|
.map(|s| s.unit.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
}
|
|
if let Some(inst) = state.packages.get(name) {
|
|
ui::kv("installed", &ui::short_date(&inst.installed_at));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
|
|
let index = manifest::load(false, track)?;
|
|
let state = state::State::load()?;
|
|
|
|
let targets: Vec<String> = match name {
|
|
Some(n) => {
|
|
if index.get(n).is_none() {
|
|
bail!("unknown package: {n}");
|
|
}
|
|
vec![n.to_string()]
|
|
}
|
|
None => state.packages.keys().cloned().collect(),
|
|
};
|
|
|
|
if targets.is_empty() {
|
|
println!("{}", ui::dim("no packages installed — nothing to check"));
|
|
return Ok(());
|
|
}
|
|
|
|
let mut targets = targets;
|
|
targets.sort();
|
|
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
|
|
let name_w = ui::name_width(&targets);
|
|
|
|
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,
|
|
&pkg.optional_system_deps,
|
|
name_w,
|
|
) {
|
|
all_ok = false;
|
|
}
|
|
} else {
|
|
ui::check_row(
|
|
false,
|
|
pkg_name,
|
|
name_w,
|
|
"not found in index (removed from registry?)",
|
|
);
|
|
all_ok = false;
|
|
}
|
|
|
|
// System-deps checks alone can't catch a partially-broken install
|
|
// (e.g. a binary manually deleted after install) — also confirm
|
|
// every binary this package recorded is still on disk. Existence
|
|
// only, not a checksum re-verification — see `bakery verify` for that.
|
|
if let Some(installed) = state.packages.get(pkg_name) {
|
|
for bin in &installed.binaries {
|
|
let path = bin_dir.join(bin);
|
|
if !path.exists() {
|
|
eprintln!(
|
|
" {}",
|
|
ui::fail(&format!(
|
|
"{pkg_name}: recorded binary '{bin}' is missing at {}",
|
|
path.display()
|
|
))
|
|
);
|
|
all_ok = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if all_ok {
|
|
ui::summary(&[&ui::ok("all checks passed")]);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum VerifyStatus {
|
|
Ok,
|
|
Missing,
|
|
Tampered,
|
|
/// `binary_sha256` has no entry for this binary — an install that
|
|
/// predates `bakery verify` support. Reported plainly rather than
|
|
/// folded into `Ok`, since there's nothing to actually compare against.
|
|
Unknown,
|
|
}
|
|
|
|
/// Recomputes `binary_name`'s on-disk sha256 in `bin_dir` and compares it
|
|
/// against `expected` — the hash recorded in `InstalledPackage::
|
|
/// binary_sha256` at install time, not a fresh index lookup. The index
|
|
/// only carries the checksum for whatever the *current latest* release is,
|
|
/// which may not be what's actually installed if the user hasn't updated
|
|
/// yet; comparing against that would produce a false "tampered" report for
|
|
/// a perfectly intact, just-not-latest binary.
|
|
fn verify_binary(bin_dir: &Path, binary_name: &str, expected: Option<&String>) -> VerifyStatus {
|
|
let Some(expected) = expected else {
|
|
return VerifyStatus::Unknown;
|
|
};
|
|
let path = bin_dir.join(binary_name);
|
|
let Ok(bytes) = std::fs::read(&path) else {
|
|
return VerifyStatus::Missing;
|
|
};
|
|
let actual = hex::encode(Sha256::digest(&bytes));
|
|
if &actual == expected {
|
|
VerifyStatus::Ok
|
|
} else {
|
|
VerifyStatus::Tampered
|
|
}
|
|
}
|
|
|
|
fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
|
let state = state::State::load()?;
|
|
|
|
let targets: Vec<String> = match name {
|
|
Some(n) => {
|
|
if !state.is_installed(n) {
|
|
bail!("{n} is not installed");
|
|
}
|
|
vec![n.to_string()]
|
|
}
|
|
None => state.packages.keys().cloned().collect(),
|
|
};
|
|
|
|
if targets.is_empty() {
|
|
println!("{}", ui::dim("no packages installed — nothing to verify"));
|
|
return Ok(());
|
|
}
|
|
|
|
let mut targets = targets;
|
|
targets.sort();
|
|
ui::heading("Verify", &[&format!("{} packages", targets.len())]);
|
|
let name_w = ui::name_width(&targets);
|
|
|
|
let mut any_bad = false;
|
|
for pkg_name in &targets {
|
|
let installed = &state.packages[pkg_name];
|
|
if installed.binary_sha256.is_empty() {
|
|
ui::unknown_row(
|
|
pkg_name,
|
|
name_w,
|
|
"no recorded checksums (installed before verify support)",
|
|
);
|
|
continue;
|
|
}
|
|
for bin in &installed.binaries {
|
|
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
|
VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin),
|
|
VerifyStatus::Missing => {
|
|
ui::check_row(false, pkg_name, name_w, &format!("{bin} missing"));
|
|
any_bad = true;
|
|
}
|
|
VerifyStatus::Tampered => {
|
|
ui::check_row(
|
|
false,
|
|
pkg_name,
|
|
name_w,
|
|
&format!("{bin} tampered (checksum mismatch)"),
|
|
);
|
|
any_bad = true;
|
|
}
|
|
VerifyStatus::Unknown => {
|
|
ui::unknown_row(
|
|
pkg_name,
|
|
name_w,
|
|
&format!("{bin} no recorded checksum for this binary"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if any_bad {
|
|
bail!("verification failed for one or more binaries");
|
|
}
|
|
ui::summary(&[&ui::ok("all recorded checksums match")]);
|
|
Ok(())
|
|
}
|
|
|
|
/// Copies each of `binaries` from `backup_dir` into `bin_dir` (atomic,
|
|
/// executable — same as a normal install), returning the sha256 of each
|
|
/// restored binary so the caller can update `InstalledPackage::
|
|
/// binary_sha256` to match what's now actually on disk. The hash is
|
|
/// computed from the trusted local backup bytes directly, not re-verified
|
|
/// against any network source — see `install::backup_current_binary` for
|
|
/// why rollback is backup-based rather than a network re-pin in the first
|
|
/// place. Pure with respect to global state (caller supplies both dirs), so
|
|
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
|
fn restore_binaries(
|
|
backup_dir: &Path,
|
|
binaries: &[String],
|
|
bin_dir: &Path,
|
|
) -> Result<HashMap<String, String>> {
|
|
let mut sha256 = HashMap::new();
|
|
for bin in binaries {
|
|
let backup_path = backup_dir.join(bin);
|
|
if !backup_path.exists() {
|
|
bail!(
|
|
"backup for binary '{bin}' is missing at {}",
|
|
backup_path.display()
|
|
);
|
|
}
|
|
let bytes = std::fs::read(&backup_path)
|
|
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
|
let hash = hex::encode(Sha256::digest(&bytes));
|
|
let dest = bin_dir.join(bin);
|
|
bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
|
|
.with_context(|| format!("restoring {}", dest.display()))?;
|
|
sha256.insert(bin.clone(), hash);
|
|
}
|
|
Ok(sha256)
|
|
}
|
|
|
|
/// Rolls `pkg_name` back to its previously installed version, restoring
|
|
/// binaries from the local pre-update backup `install::install_package`
|
|
/// made — not by re-fetching the old version from `dl.breadway.dev`.
|
|
/// Deliberately backup-based: `index.json`'s minisign signature only covers
|
|
/// the *current* published version's checksums, so verifying an old version
|
|
/// pulled fresh from the server would only be checkable against its
|
|
/// unsigned per-version `.sha256` sidecar file, a materially weaker
|
|
/// guarantee than bakery's normal trust model. The local backup sidesteps
|
|
/// that gap — it's bytes bakery itself copied from a binary that was
|
|
/// already verified against a signed index at the time it was installed.
|
|
fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
|
let installed = state::State::load()?
|
|
.packages
|
|
.get(pkg_name)
|
|
.cloned()
|
|
.ok_or_else(|| anyhow::anyhow!("{pkg_name} is not installed"))?;
|
|
|
|
let target_version = installed.previous_version.clone().ok_or_else(|| {
|
|
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
|
|
})?;
|
|
|
|
ui::action("Rolling back", pkg_name, Some(&target_version));
|
|
|
|
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
|
if !backup_dir.exists() {
|
|
bail!(
|
|
"no local backup found for {pkg_name} {target_version} at {} — cannot roll back",
|
|
backup_dir.display()
|
|
);
|
|
}
|
|
|
|
let binary_sha256 = restore_binaries(&backup_dir, &installed.binaries, bin_dir)?;
|
|
|
|
let from_version = installed.version.clone();
|
|
state::State::with_lock(|state| {
|
|
if let Some(pkg) = state.packages.get_mut(pkg_name) {
|
|
pkg.version = target_version.clone();
|
|
pkg.previous_version = Some(from_version.clone());
|
|
pkg.binary_sha256 = binary_sha256.clone();
|
|
}
|
|
Ok(())
|
|
})?;
|
|
|
|
// Best-effort — a leftover backup dir after a successful rollback just
|
|
// wastes disk, it's not a correctness problem, so a failure here
|
|
// shouldn't fail the rollback itself.
|
|
let _ = std::fs::remove_dir_all(&backup_dir);
|
|
|
|
println!(
|
|
" {}",
|
|
ui::ok(&format!(
|
|
"rolled back {pkg_name} {} → {target_version}",
|
|
ui::dim(&from_version)
|
|
))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn is_newer_detects_real_semver_increase() {
|
|
assert!(is_newer("0.3.1", "0.3.2"));
|
|
assert!(is_newer("0.3.1", "0.4.0"));
|
|
assert!(!is_newer("0.3.2", "0.3.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_newer_false_when_equal() {
|
|
assert!(!is_newer("0.3.1", "0.3.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_newer_orders_dev_prereleases_within_a_track() {
|
|
// Two dev builds of the same upcoming patch, ordered by their
|
|
// timestamp+sha build suffix.
|
|
assert!(is_newer(
|
|
"0.3.2-dev.20260722120000+aaa1111",
|
|
"0.3.2-dev.20260722130000+bbb2222"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn is_newer_falls_back_to_inequality_on_unparseable_versions() {
|
|
// Pre-semver version strings should never hard-fail an update check.
|
|
assert!(is_newer("weird-version-1", "weird-version-2"));
|
|
assert!(!is_newer("weird-version-1", "weird-version-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
|
// "bakery track set stable && bakery update --all" from beta must
|
|
// always take effect, even though 0.3.0 < 0.4.0-beta by strict semver.
|
|
assert!(should_update(
|
|
"0.4.0-beta",
|
|
Track::Beta,
|
|
Track::Stable,
|
|
"0.3.0"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn should_update_false_when_same_track_and_not_newer() {
|
|
assert!(!should_update(
|
|
"0.3.1",
|
|
Track::Stable,
|
|
Track::Stable,
|
|
"0.3.1"
|
|
));
|
|
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_update_true_when_same_track_and_newer() {
|
|
assert!(should_update(
|
|
"0.3.1",
|
|
Track::Stable,
|
|
Track::Stable,
|
|
"0.3.2"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn matches_search_matches_name_case_insensitively() {
|
|
assert!(matches_search("BreadHelp", "onboarding guide", "breadhelp"));
|
|
}
|
|
|
|
#[test]
|
|
fn matches_search_matches_description_substring() {
|
|
assert!(matches_search("breadhelp", "Onboarding Guide", "onboard"));
|
|
}
|
|
|
|
#[test]
|
|
fn matches_search_no_match_returns_false() {
|
|
assert!(!matches_search("breadhelp", "onboarding guide", "zzz"));
|
|
}
|
|
|
|
fn empty_binary_package(name: &str, version: &str, url: &str) -> manifest::Package {
|
|
manifest::Package {
|
|
name: name.to_string(),
|
|
description: "test".to_string(),
|
|
version: version.to_string(),
|
|
binaries: vec![manifest::Binary {
|
|
name: name.to_string(),
|
|
dl_url: url.to_string(),
|
|
github_url: url.to_string(),
|
|
sha256: "0".repeat(64),
|
|
}],
|
|
system_deps: vec![],
|
|
optional_system_deps: vec![],
|
|
bread_deps: vec![],
|
|
services: vec![],
|
|
config: None,
|
|
post_install: vec![],
|
|
license_file: None,
|
|
license_file_sha256: None,
|
|
desktop_file: None,
|
|
desktop_file_sha256: None,
|
|
data_archive: None,
|
|
data_archive_sha256: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn install_with_deps_dry_run_does_not_download_or_write() {
|
|
let name = "faketestpkg-dry-run";
|
|
// A reserved, essentially-guaranteed-unreachable port: if dry-run
|
|
// ever regressed into actually calling fetch_and_place, this would
|
|
// fail loudly (connection refused) instead of silently passing.
|
|
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
|
let mut packages = std::collections::HashMap::new();
|
|
packages.insert(name.to_string(), pkg);
|
|
let index = manifest::Index {
|
|
version: "1".to_string(),
|
|
packages,
|
|
};
|
|
|
|
let bin_dir = tempdir().unwrap();
|
|
let mut visited = HashSet::new();
|
|
install_with_deps(
|
|
&index,
|
|
name,
|
|
bin_dir.path(),
|
|
Track::Stable,
|
|
true,
|
|
true,
|
|
true, // dry_run
|
|
&mut visited,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(fs::read_dir(bin_dir.path()).unwrap().next().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn verify_binary_ok_when_hash_matches() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
|
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
|
assert_eq!(
|
|
verify_binary(dir.path(), "mypkg", Some(&hash)),
|
|
VerifyStatus::Ok
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_binary_tampered_when_hash_mismatches() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
|
let wrong_hash = "0".repeat(64);
|
|
assert_eq!(
|
|
verify_binary(dir.path(), "mypkg", Some(&wrong_hash)),
|
|
VerifyStatus::Tampered
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_binary_missing_when_file_absent() {
|
|
let dir = tempdir().unwrap();
|
|
let hash = "0".repeat(64);
|
|
assert_eq!(
|
|
verify_binary(dir.path(), "nope", Some(&hash)),
|
|
VerifyStatus::Missing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_binary_unknown_when_no_recorded_hash() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
|
assert_eq!(
|
|
verify_binary(dir.path(), "mypkg", None),
|
|
VerifyStatus::Unknown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_binaries_round_trips_backup_into_bin_dir() {
|
|
let dir = tempdir().unwrap();
|
|
let backup_dir = dir.path().join("backup");
|
|
fs::create_dir_all(&backup_dir).unwrap();
|
|
fs::write(backup_dir.join("mypkg"), b"old version bytes").unwrap();
|
|
let bin_dir = dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).unwrap();
|
|
|
|
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read(bin_dir.join("mypkg")).unwrap(),
|
|
b"old version bytes"
|
|
);
|
|
assert_eq!(
|
|
hashes["mypkg"],
|
|
hex::encode(Sha256::digest(b"old version bytes"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_binaries_errors_clearly_when_backup_missing() {
|
|
let dir = tempdir().unwrap();
|
|
let backup_dir = dir.path().join("backup");
|
|
let bin_dir = dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).unwrap();
|
|
|
|
let err = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("missing"));
|
|
assert!(fs::read_dir(&bin_dir).unwrap().next().is_none());
|
|
}
|
|
}
|