bakery: restyle CLI output with headers, columns, and progress
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.
This commit is contained in:
parent
30517f1617
commit
3f1caa99f5
6 changed files with 973 additions and 285 deletions
|
|
@ -11,8 +11,16 @@ pub struct DepReport {
|
||||||
|
|
||||||
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||||
Ok(DepReport {
|
Ok(DepReport {
|
||||||
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
missing: required
|
||||||
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
.iter()
|
||||||
|
.filter(|d| !dep_present(d))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
warnings: optional
|
||||||
|
.iter()
|
||||||
|
.filter(|d| !dep_present(d))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,14 +107,24 @@ pub fn install_hint(missing: &[String]) -> String {
|
||||||
|
|
||||||
/// Print a formatted doctor report for a package's system deps.
|
/// Print a formatted doctor report for a package's system deps.
|
||||||
/// Returns true if all *required* deps are satisfied.
|
/// Returns true if all *required* deps are satisfied.
|
||||||
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
pub fn report(
|
||||||
|
package_name: &str,
|
||||||
|
required: &[String],
|
||||||
|
optional: &[String],
|
||||||
|
name_width: usize,
|
||||||
|
) -> bool {
|
||||||
if required.is_empty() && optional.is_empty() {
|
if required.is_empty() && optional.is_empty() {
|
||||||
println!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
|
ui::check_row(true, package_name, name_width, "no system deps required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match check_deps(required, optional) {
|
match check_deps(required, optional) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
|
ui::check_row(
|
||||||
|
false,
|
||||||
|
package_name,
|
||||||
|
name_width,
|
||||||
|
&format!("error running doctor: {e}"),
|
||||||
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Ok(rep) => {
|
Ok(rep) => {
|
||||||
|
|
@ -123,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if rep.missing.is_empty() {
|
if rep.missing.is_empty() {
|
||||||
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
|
ui::check_row(
|
||||||
|
true,
|
||||||
|
package_name,
|
||||||
|
name_width,
|
||||||
|
"all required system deps satisfied",
|
||||||
|
);
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
ui::check_row(
|
||||||
|
false,
|
||||||
|
package_name,
|
||||||
|
name_width,
|
||||||
|
&format!("missing: {}", rep.missing.join(", ")),
|
||||||
|
);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::fail(&format!(
|
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
|
||||||
"{package_name}: missing system deps: {}",
|
|
||||||
rep.missing.join(", ")
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
eprintln!(" install with: {}", install_hint(&rep.missing));
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,22 +212,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_required_dep_detected() {
|
fn missing_required_dep_detected() {
|
||||||
let rep = check_deps(
|
let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap();
|
||||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rep.missing.len(), 1);
|
assert_eq!(rep.missing.len(), 1);
|
||||||
assert!(rep.warnings.is_empty());
|
assert!(rep.warnings.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_optional_dep_becomes_warning_not_error() {
|
fn missing_optional_dep_becomes_warning_not_error() {
|
||||||
let rep = check_deps(
|
let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap();
|
||||||
&[],
|
|
||||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(rep.missing.is_empty());
|
assert!(rep.missing.is_empty());
|
||||||
assert_eq!(rep.warnings.len(), 1);
|
assert_eq!(rep.warnings.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::manifest::{fetch_binary, Binary};
|
use crate::manifest::{fetch_binary, Binary};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
/// Download a binary, verify its SHA-256, then atomically write it into
|
/// Download a binary, verify its SHA-256, then atomically write it into
|
||||||
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
||||||
|
|
@ -12,7 +13,7 @@ use crate::manifest::{fetch_binary, Binary};
|
||||||
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
||||||
/// `binary.sha256`, so that's the value to return.
|
/// `binary.sha256`, so that's the value to return.
|
||||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||||
println!(" downloading {}…", binary.name);
|
ui::step("downloading", &binary.name);
|
||||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||||
.with_context(|| format!("downloading {}", binary.name))?;
|
.with_context(|| format!("downloading {}", binary.name))?;
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||||
|
|
||||||
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
||||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||||
println!(" installed {}", dest.display());
|
ui::step("placed", &dest.display().to_string());
|
||||||
Ok(binary.sha256.clone())
|
Ok(binary.sha256.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::download::{fetch_and_place, verify_sha256};
|
||||||
use crate::manifest::{fetch_binary, Package, Service};
|
use crate::manifest::{fetch_binary, Package, Service};
|
||||||
use crate::state::{InstalledPackage, State};
|
use crate::state::{InstalledPackage, State};
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
/// Rejects a filename that isn't a safe single path component — no `/`,
|
/// Rejects a filename that isn't a safe single path component — no `/`,
|
||||||
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
|
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
|
||||||
|
|
@ -52,7 +53,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
print!("{prompt} [y/N] ");
|
print!("{prompt} {} ", ui::dim("[y/N]"));
|
||||||
std::io::stdout().flush().ok();
|
std::io::stdout().flush().ok();
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
std::io::stdin().read_line(&mut buf).ok();
|
std::io::stdin().read_line(&mut buf).ok();
|
||||||
|
|
@ -74,7 +75,6 @@ pub fn install_package(
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ensure_safe_component(&pkg.name, "package name")?;
|
ensure_safe_component(&pkg.name, "package name")?;
|
||||||
println!("installing {}@{}…", pkg.name, pkg.version);
|
|
||||||
|
|
||||||
// 1. Download and verify all binaries. On an update (not a fresh
|
// 1. Download and verify all binaries. On an update (not a fresh
|
||||||
// install), back up the current binary first — best-effort, feeding
|
// install), back up the current binary first — best-effort, feeding
|
||||||
|
|
@ -126,10 +126,13 @@ pub fn install_package(
|
||||||
// confirmation rather than running unconditionally.
|
// confirmation rather than running unconditionally.
|
||||||
if !pkg.post_install.is_empty() {
|
if !pkg.post_install.is_empty() {
|
||||||
if no_hooks {
|
if no_hooks {
|
||||||
println!(
|
eprintln!(
|
||||||
" note: skipped {} post_install hook(s) for {} (--no-hooks)",
|
" {}",
|
||||||
|
ui::note(&format!(
|
||||||
|
"skipped {} post_install hook(s) for {} (--no-hooks)",
|
||||||
pkg.post_install.len(),
|
pkg.post_install.len(),
|
||||||
pkg.name
|
pkg.name
|
||||||
|
))
|
||||||
);
|
);
|
||||||
} else if confirm(
|
} else if confirm(
|
||||||
&format!(
|
&format!(
|
||||||
|
|
@ -143,7 +146,13 @@ pub fn install_package(
|
||||||
run_hook(cmd, &pkg.name)?;
|
run_hook(cmd, &pkg.name)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!(" skipped post_install hooks for {} (declined)", pkg.name);
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::note(&format!(
|
||||||
|
"skipped post_install hooks for {} (declined)",
|
||||||
|
pkg.name
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +172,7 @@ pub fn install_package(
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!(" {} installed successfully", pkg.name);
|
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
|
||||||
warn_path_if_needed(bin_dir);
|
warn_path_if_needed(bin_dir);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -184,13 +193,21 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: could not create backup dir {} ({e}) — rollback won't be available for this update",
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"could not create backup dir {} ({e}) — rollback won't be available for this update",
|
||||||
backup_dir.display()
|
backup_dir.display()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
||||||
eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"could not back up {binary_name} before update ({e}) — rollback won't be available for this update"
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +216,11 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
let installed = match installed {
|
let installed = match installed {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!("{pkg_name} is not installed");
|
eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed")));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ui::action("Removing", pkg_name, Some(&installed.version));
|
||||||
// State is already committed by with_lock above — everything from here
|
// State is already committed by with_lock above — everything from here
|
||||||
// is best-effort file cleanup, and must all run even if part of it fails.
|
// is best-effort file cleanup, and must all run even if part of it fails.
|
||||||
|
|
||||||
|
|
@ -214,7 +232,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
let path = bin_dir.join(bin);
|
let path = bin_dir.join(bin);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
match std::fs::remove_file(&path) {
|
match std::fs::remove_file(&path) {
|
||||||
Ok(()) => println!(" removed {}", path.display()),
|
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +253,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "daemon-reload"])
|
.args(["--user", "daemon-reload"])
|
||||||
.status();
|
.status();
|
||||||
println!(" removed unit {unit}");
|
ui::step("removed", &format!("unit {unit}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +265,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
// surprise no flag should cause.
|
// surprise no flag should cause.
|
||||||
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||||
if cfg_dir.exists() {
|
if cfg_dir.exists() {
|
||||||
println!(" config preserved at {}", cfg_dir.display());
|
ui::step("preserved", &format!("config {}", cfg_dir.display()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,23 +276,34 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
let license_dir = share_dir.join("licenses").join(pkg_name);
|
let license_dir = share_dir.join("licenses").join(pkg_name);
|
||||||
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
||||||
|
|
||||||
let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop"));
|
let desktop_file = share_dir
|
||||||
remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures);
|
.join("applications")
|
||||||
|
.join(format!("{pkg_name}.desktop"));
|
||||||
|
remove_purged_path(
|
||||||
|
&desktop_file,
|
||||||
|
"desktop entry",
|
||||||
|
false,
|
||||||
|
assume_yes,
|
||||||
|
&mut failures,
|
||||||
|
);
|
||||||
|
|
||||||
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
|
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
|
||||||
} else if data_dir.exists() {
|
} else if data_dir.exists() {
|
||||||
println!(" data preserved at {}", data_dir.display());
|
ui::step("preserved", &format!("data {}", data_dir.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !failures.is_empty() {
|
if !failures.is_empty() {
|
||||||
eprintln!(" failed to remove {} item(s):", failures.len());
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("failed to remove {} item(s):", failures.len()))
|
||||||
|
);
|
||||||
for f in &failures {
|
for f in &failures {
|
||||||
eprintln!(" {f}");
|
eprintln!(" {f}");
|
||||||
}
|
}
|
||||||
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" {pkg_name} removed");
|
println!(" {}", ui::ok(&format!("{pkg_name} removed")));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,12 +313,21 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
||||||
/// in place and prints the same "preserved at" wording the non-purge path
|
/// in place and prints the same "preserved at" wording the non-purge path
|
||||||
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
||||||
/// (license dir, desktop entry, data dir).
|
/// (license dir, desktop entry, data dir).
|
||||||
fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec<String>) {
|
fn remove_purged_path(
|
||||||
|
path: &Path,
|
||||||
|
label: &str,
|
||||||
|
recursive: bool,
|
||||||
|
assume_yes: bool,
|
||||||
|
failures: &mut Vec<String>,
|
||||||
|
) {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) {
|
if !confirm(
|
||||||
println!(" {label} preserved at {}", path.display());
|
&format!(" remove {label} at {}?", path.display()),
|
||||||
|
assume_yes,
|
||||||
|
) {
|
||||||
|
ui::step("preserved", &format!("{label} {}", path.display()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let result = if recursive {
|
let result = if recursive {
|
||||||
|
|
@ -298,7 +336,7 @@ fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: boo
|
||||||
std::fs::remove_file(path)
|
std::fs::remove_file(path)
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => println!(" removed {}", path.display()),
|
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,36 +356,48 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
std::fs::write(&dest, &bytes)
|
std::fs::write(&dest, &bytes)
|
||||||
.with_context(|| format!("writing {}", dest.display()))?;
|
.with_context(|| format!("writing {}", dest.display()))?;
|
||||||
println!(" installed example config at {}", dest.display());
|
ui::step("config", &dest.display().to_string());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: checksum mismatch for example config {example}: {e} — not installed"
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"checksum mismatch for example config {example}: {e} — not installed"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
println!(" config dir created at {}", dir.display());
|
ui::step("config", &dir.display().to_string());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: index.json has no sha256 for example config \
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"index.json has no sha256 for example config \
|
||||||
{example} — refusing to install an unverified download"
|
{example} — refusing to install an unverified download"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
println!(" config dir created at {}", dir.display());
|
ui::step("config", &dir.display().to_string());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" warning: could not download example config {example}: {e}");
|
eprintln!(
|
||||||
println!(" config dir created at {}", dir.display());
|
" {}",
|
||||||
|
ui::warn(&format!("could not download example config {example}: {e}"))
|
||||||
|
);
|
||||||
|
ui::step("config", &dir.display().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!(" config dir created at {}", dir.display());
|
ui::step("config", &dir.display().to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!(" config at {} already exists, skipping", dest.display());
|
ui::step(
|
||||||
|
"config",
|
||||||
|
&format!("{} already exists, skipping", dest.display()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!(" config dir created at {}", dir.display());
|
ui::step("config", &dir.display().to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -366,32 +416,46 @@ fn fetch_verify_write(
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
|
let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
|
||||||
eprintln!(" warning: no artifact URL to download {label} ({filename})");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::warn(&format!("no artifact URL to download {label} ({filename})"))
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let bytes = match fetch_binary(&primary, &fallback) {
|
let bytes = match fetch_binary(&primary, &fallback) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" warning: could not download {label} {filename}: {e}");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::warn(&format!("could not download {label} {filename}: {e}"))
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(expected) = sha256 else {
|
let Some(expected) = sha256 else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: index.json has no sha256 for {label} {filename} — \
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"index.json has no sha256 for {label} {filename} — \
|
||||||
refusing to install an unverified download"
|
refusing to install an unverified download"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if let Err(e) = verify_sha256(&bytes, expected) {
|
if let Err(e) = verify_sha256(&bytes, expected) {
|
||||||
eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"checksum mismatch for {label} {filename}: {e} — not installed"
|
||||||
|
))
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
|
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
|
||||||
println!(" installed {label} at {}", dest.display());
|
ui::step("installed", &format!("{label} {}", dest.display()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,7 +475,13 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
.join("applications")
|
.join("applications")
|
||||||
.join(format!("{}.desktop", pkg.name));
|
.join(format!("{}.desktop", pkg.name));
|
||||||
fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry")
|
fetch_verify_write(
|
||||||
|
pkg,
|
||||||
|
filename,
|
||||||
|
&pkg.desktop_file_sha256,
|
||||||
|
&dest,
|
||||||
|
"desktop entry",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
|
fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
|
||||||
|
|
@ -468,9 +538,15 @@ fn fetch_extract_archive(
|
||||||
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
|
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
|
||||||
|
|
||||||
if status.success() {
|
if status.success() {
|
||||||
println!(" extracted {filename} to {}", dest_dir.display());
|
ui::step(
|
||||||
|
"extracted",
|
||||||
|
&format!("{filename} → {}", dest_dir.display()),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: tar exited with {status} extracting {filename}");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::warn(&format!("tar exited with {status} extracting {filename}"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -552,18 +628,24 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
std::fs::write(&unit_path, &bytes)
|
std::fs::write(&unit_path, &bytes)
|
||||||
.with_context(|| format!("writing {}", unit_path.display()))?;
|
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||||
println!(" downloaded unit {}", unit_path.display());
|
ui::step("unit", &unit_path.display().to_string());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if had_existing {
|
if had_existing {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: could not refresh unit {} ({e}) — keeping existing copy",
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"could not refresh unit {} ({e}) — keeping existing copy",
|
||||||
svc.unit
|
svc.unit
|
||||||
|
))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" warning: unit file {} not found ({e}) — skipping service setup",
|
" {}",
|
||||||
|
ui::warn(&format!(
|
||||||
|
"unit file {} not found ({e}) — skipping service setup",
|
||||||
svc.unit
|
svc.unit
|
||||||
|
))
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -578,7 +660,7 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
eprintln!(" warning: systemctl daemon-reload failed");
|
eprintln!(" {}", ui::warn("systemctl daemon-reload failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.enable {
|
if svc.enable {
|
||||||
|
|
@ -595,9 +677,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
println!(" {} restarted", svc.unit);
|
ui::step("restarted", &svc.unit);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to restart {}", svc.unit);
|
eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit)));
|
||||||
}
|
}
|
||||||
} else if Command::new("systemctl")
|
} else if Command::new("systemctl")
|
||||||
.args(["--user", "enable", "--now", &svc.unit])
|
.args(["--user", "enable", "--now", &svc.unit])
|
||||||
|
|
@ -605,9 +687,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
println!(" {} enabled and started", svc.unit);
|
ui::step("enabled", &svc.unit);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to enable {}", svc.unit);
|
eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,13 +732,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
||||||
println!(" running post_install hook: {cmd}");
|
ui::step("hook", cmd);
|
||||||
let status = Command::new("sh")
|
let status = Command::new("sh")
|
||||||
.args(["-c", cmd])
|
.args(["-c", cmd])
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
eprintln!(" warning: hook exited with {status}");
|
eprintln!(" {}", ui::warn(&format!("hook exited with {status}")));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -699,11 +781,14 @@ fn warn_path_if_needed(bin_dir: &Path) {
|
||||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||||
let bin_str = bin_dir.to_string_lossy();
|
let bin_str = bin_dir.to_string_lossy();
|
||||||
if !path_var.split(':').any(|p| p == bin_str) {
|
if !path_var.split(':').any(|p| p == bin_str) {
|
||||||
println!(
|
eprintln!();
|
||||||
"\n note: {} is not in PATH — add to your shell profile:",
|
eprintln!(
|
||||||
bin_str
|
" {}",
|
||||||
|
ui::note(&format!(
|
||||||
|
"{bin_str} is not in PATH — add to your shell profile:"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
println!(" export PATH=\"{bin_str}:$PATH\"");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -728,10 +813,7 @@ mod tests {
|
||||||
if let Ok((mut stream, _)) = listener.accept() {
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
let mut buf = [0u8; 1024];
|
let mut buf = [0u8; 1024];
|
||||||
let _ = stream.read(&mut buf);
|
let _ = stream.read(&mut buf);
|
||||||
let response = format!(
|
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
||||||
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(body);
|
let _ = stream.write_all(body);
|
||||||
}
|
}
|
||||||
|
|
@ -748,10 +830,7 @@ mod tests {
|
||||||
if let Ok((mut stream, _)) = listener.accept() {
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
let mut buf = [0u8; 1024];
|
let mut buf = [0u8; 1024];
|
||||||
let _ = stream.read(&mut buf);
|
let _ = stream.read(&mut buf);
|
||||||
let response = format!(
|
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
||||||
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(&body);
|
let _ = stream.write_all(&body);
|
||||||
}
|
}
|
||||||
|
|
@ -797,7 +876,13 @@ mod tests {
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let dest = dir.path().join("LICENSE");
|
let dest = dir.path().join("LICENSE");
|
||||||
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
fetch_verify_write(
|
||||||
|
&pkg,
|
||||||
|
"LICENSE",
|
||||||
|
&pkg.license_file_sha256.clone(),
|
||||||
|
&dest,
|
||||||
|
"license",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(fs::read(&dest).unwrap(), license_bytes);
|
assert_eq!(fs::read(&dest).unwrap(), license_bytes);
|
||||||
|
|
@ -836,7 +921,13 @@ mod tests {
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let dest = dir.path().join("LICENSE");
|
let dest = dir.path().join("LICENSE");
|
||||||
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
fetch_verify_write(
|
||||||
|
&pkg,
|
||||||
|
"LICENSE",
|
||||||
|
&pkg.license_file_sha256.clone(),
|
||||||
|
&dest,
|
||||||
|
"license",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Refused, not erred (matches scaffold_config's warn-and-continue
|
// Refused, not erred (matches scaffold_config's warn-and-continue
|
||||||
|
|
@ -885,8 +976,7 @@ mod tests {
|
||||||
let pkg = test_package(&base_url);
|
let pkg = test_package(&base_url);
|
||||||
|
|
||||||
let dest_dir = tempdir().unwrap();
|
let dest_dir = tempdir().unwrap();
|
||||||
fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path())
|
fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()).unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let extracted = dest_dir.path().join("content/tours/onboarding.toml");
|
let extracted = dest_dir.path().join("content/tours/onboarding.toml");
|
||||||
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
|
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
|
||||||
|
|
@ -1043,7 +1133,10 @@ mod tests {
|
||||||
|
|
||||||
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
||||||
|
|
||||||
assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes");
|
assert_eq!(
|
||||||
|
fs::read(backup_dir.join("mypkg")).unwrap(),
|
||||||
|
b"old version bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ use std::path::{Path, PathBuf};
|
||||||
use track::Track;
|
use track::Track;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
|
#[command(
|
||||||
|
name = "bakery",
|
||||||
|
about = "Package manager for the bread ecosystem",
|
||||||
|
version,
|
||||||
|
styles = ui::CLAP_STYLES
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Cmd,
|
command: Cmd,
|
||||||
|
|
@ -63,13 +68,9 @@ enum Cmd {
|
||||||
installed: bool,
|
installed: bool,
|
||||||
},
|
},
|
||||||
/// Show details for a package
|
/// Show details for a package
|
||||||
Info {
|
Info { package: String },
|
||||||
package: String,
|
|
||||||
},
|
|
||||||
/// Search package names and descriptions
|
/// Search package names and descriptions
|
||||||
Search {
|
Search { query: String },
|
||||||
query: String,
|
|
||||||
},
|
|
||||||
/// Check system dependencies for installed or requested packages
|
/// Check system dependencies for installed or requested packages
|
||||||
Doctor {
|
Doctor {
|
||||||
/// Package to check; omit to check all installed packages
|
/// Package to check; omit to check all installed packages
|
||||||
|
|
@ -82,15 +83,11 @@ enum Cmd {
|
||||||
},
|
},
|
||||||
/// Roll back a package to its previously installed version, from a
|
/// Roll back a package to its previously installed version, from a
|
||||||
/// local pre-update backup (not a re-download)
|
/// local pre-update backup (not a re-download)
|
||||||
Rollback {
|
Rollback { package: String },
|
||||||
package: String,
|
|
||||||
},
|
|
||||||
/// Update bakery itself
|
/// Update bakery itself
|
||||||
SelfUpdate,
|
SelfUpdate,
|
||||||
/// Generate a shell completion script
|
/// Generate a shell completion script
|
||||||
Completions {
|
Completions { shell: clap_complete::Shell },
|
||||||
shell: clap_complete::Shell,
|
|
||||||
},
|
|
||||||
/// View or switch which build track bakery follows (stable/beta/dev)
|
/// View or switch which build track bakery follows (stable/beta/dev)
|
||||||
Track {
|
Track {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -130,9 +127,15 @@ fn main() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
||||||
Cmd::Update { package, all } => {
|
Cmd::Update { package, all } => cmd_update(
|
||||||
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run)
|
package.as_deref(),
|
||||||
}
|
all,
|
||||||
|
&bin_dir,
|
||||||
|
track,
|
||||||
|
no_hooks,
|
||||||
|
assume_yes,
|
||||||
|
dry_run,
|
||||||
|
),
|
||||||
Cmd::List { installed } => cmd_list(installed, track),
|
Cmd::List { installed } => cmd_list(installed, track),
|
||||||
Cmd::Info { package } => cmd_info(&package, track),
|
Cmd::Info { package } => cmd_info(&package, track),
|
||||||
Cmd::Search { query } => cmd_search(&query, track),
|
Cmd::Search { query } => cmd_search(&query, track),
|
||||||
|
|
@ -144,7 +147,15 @@ fn main() -> Result<()> {
|
||||||
// bakery's own running binary via a normal update already works
|
// bakery's own running binary via a normal update already works
|
||||||
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
||||||
// first-class command.
|
// first-class command.
|
||||||
Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run),
|
Cmd::SelfUpdate => cmd_update(
|
||||||
|
Some("bakery"),
|
||||||
|
false,
|
||||||
|
&bin_dir,
|
||||||
|
track,
|
||||||
|
no_hooks,
|
||||||
|
assume_yes,
|
||||||
|
dry_run,
|
||||||
|
),
|
||||||
Cmd::Completions { shell } => cmd_completions(shell),
|
Cmd::Completions { shell } => cmd_completions(shell),
|
||||||
Cmd::Track { action } => cmd_track(action),
|
Cmd::Track { action } => cmd_track(action),
|
||||||
}
|
}
|
||||||
|
|
@ -159,11 +170,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
match action {
|
match action {
|
||||||
TrackCmd::Show => {
|
TrackCmd::Show => {
|
||||||
println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
|
ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]);
|
||||||
}
|
}
|
||||||
TrackCmd::Set { track } => {
|
TrackCmd::Set { track } => {
|
||||||
if state.track == track {
|
if state.track == track {
|
||||||
println!("already on track {track}");
|
println!("{}", ui::unchanged(&format!("already on track {track}")));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Fail fast on a bad/unreachable track rather than silently
|
// Fail fast on a bad/unreachable track rather than silently
|
||||||
|
|
@ -174,10 +185,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
state.set_track(track);
|
state.set_track(track);
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
println!(
|
ui::action("Switched", track.as_str(), None);
|
||||||
"switched to {} — run 'bakery update --all' to install {} builds",
|
ui::step(
|
||||||
ui::style(track.as_str(), ui::CYAN),
|
"next",
|
||||||
track
|
&format!("bakery update --all to install {track} builds"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +206,16 @@ fn cmd_install(
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited)
|
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
|
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
||||||
|
|
@ -223,8 +243,10 @@ fn install_with_deps(
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
for dep in pkg.bread_deps.clone() {
|
for dep in pkg.bread_deps.clone() {
|
||||||
if !state.is_installed(&dep) {
|
if !state.is_installed(&dep) {
|
||||||
println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" });
|
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)?;
|
install_with_deps(
|
||||||
|
index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,26 +259,59 @@ fn install_with_deps(
|
||||||
if let Some(installed) = previous {
|
if let Some(installed) = previous {
|
||||||
if !is_newer(&installed.version, &pkg.version) {
|
if !is_newer(&installed.version, &pkg.version) {
|
||||||
println!(
|
println!(
|
||||||
"{name} already installed at {} (index has {})",
|
" {}",
|
||||||
|
ui::unchanged(&format!(
|
||||||
|
"{name} already at {} (index has {})",
|
||||||
installed.version, pkg.version
|
installed.version, pkg.version
|
||||||
|
))
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("checking system dependencies for {name}…");
|
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)?;
|
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
|
||||||
for warn in &rep.warnings {
|
for warn in &rep.warnings {
|
||||||
eprintln!(" note: optional dep not installed: {warn}");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::note(&format!("optional dep not installed: {warn}"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !rep.missing.is_empty() {
|
if !rep.missing.is_empty() {
|
||||||
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
|
eprintln!(
|
||||||
eprintln!("install with: {}", doctor::install_hint(&rep.missing));
|
" {}",
|
||||||
|
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");
|
bail!("system deps not satisfied");
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
print_dry_run_plan(pkg, previous);
|
print_dry_run_plan(pkg);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,22 +323,23 @@ fn install_with_deps(
|
||||||
/// already been made — this only renders that decision, it never
|
/// 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
|
/// recomputes it, so dry-run and real runs can't drift apart on "would this
|
||||||
/// update happen at all".
|
/// update happen at all".
|
||||||
fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) {
|
fn print_dry_run_plan(pkg: &manifest::Package) {
|
||||||
let verb = if previous.is_some() { "update" } else { "install" };
|
ui::kv(
|
||||||
println!(
|
"binaries",
|
||||||
" {} would {verb} {} to {}",
|
&pkg.binaries
|
||||||
ui::style("dry-run:", ui::DIM),
|
.iter()
|
||||||
pkg.name,
|
.map(|b| b.name.as_str())
|
||||||
ui::style(&pkg.version, ui::BOLD)
|
.collect::<Vec<_>>()
|
||||||
);
|
.join(", "),
|
||||||
println!(
|
|
||||||
" binaries: {}",
|
|
||||||
pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")
|
|
||||||
);
|
);
|
||||||
if !pkg.services.is_empty() {
|
if !pkg.services.is_empty() {
|
||||||
println!(
|
ui::kv(
|
||||||
" services: {}",
|
"services",
|
||||||
pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")
|
&pkg.services
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.unit.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -311,10 +367,16 @@ fn cmd_update(
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("no packages installed");
|
println!("{}", ui::dim("no packages installed"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut targets = targets;
|
||||||
|
targets.sort();
|
||||||
|
if all {
|
||||||
|
ui::heading("Update", &[&format!("{} packages", targets.len())]);
|
||||||
|
}
|
||||||
|
|
||||||
let mut any_failed = false;
|
let mut any_failed = false;
|
||||||
let mut updated = 0u32;
|
let mut updated = 0u32;
|
||||||
let mut unchanged = 0u32;
|
let mut unchanged = 0u32;
|
||||||
|
|
@ -322,7 +384,10 @@ fn cmd_update(
|
||||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!("{pkg_name} is not installed, skipping");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("{pkg_name} is not installed, skipping"))
|
||||||
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -330,7 +395,10 @@ fn cmd_update(
|
||||||
let latest = match index.get(pkg_name) {
|
let latest = match index.get(pkg_name) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!("{pkg_name} not found in index, skipping");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("{pkg_name} not found in index, skipping"))
|
||||||
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -350,57 +418,86 @@ fn cmd_update(
|
||||||
// under a terminal palette that maps ANSI colors unusually.
|
// under a terminal palette that maps ANSI colors unusually.
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::unchanged(&format!("{pkg_name} is already at {}", installed.version))
|
ui::unchanged(&format!("{pkg_name} already at {}", installed.version))
|
||||||
);
|
);
|
||||||
unchanged += 1;
|
unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui::action(
|
||||||
|
if dry_run { "Would update" } else { "Updating" },
|
||||||
|
pkg_name,
|
||||||
|
Some(&latest.version),
|
||||||
|
);
|
||||||
if track_switch {
|
if track_switch {
|
||||||
println!(
|
ui::step(
|
||||||
"{pkg_name} switching track {} {} {}, installing {}",
|
"track",
|
||||||
ui::style(installed.track.as_str(), ui::DIM),
|
&format!(
|
||||||
|
"{} {} {}",
|
||||||
|
ui::dim(installed.track.as_str()),
|
||||||
ui::style("→", ui::CYAN),
|
ui::style("→", ui::CYAN),
|
||||||
ui::style(track.as_str(), ui::BOLD),
|
ui::bold(track.as_str()),
|
||||||
ui::style(&latest.version, ui::BOLD)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
ui::step(
|
||||||
"updating {pkg_name} {} {} {}",
|
"version",
|
||||||
ui::style(&installed.version, ui::DIM),
|
&format!(
|
||||||
|
"{} {} {}",
|
||||||
|
ui::dim(&installed.version),
|
||||||
ui::style("→", ui::CYAN),
|
ui::style("→", ui::CYAN),
|
||||||
ui::style(&latest.version, ui::BOLD)
|
ui::bold(&latest.version)
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" doctor check failed for {pkg_name}: {e}");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("doctor check failed for {pkg_name}: {e}"))
|
||||||
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for warn in &rep.warnings {
|
for warn in &rep.warnings {
|
||||||
eprintln!(" note: optional dep not installed: {warn}");
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::note(&format!("optional dep not installed: {warn}"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !rep.missing.is_empty() {
|
if !rep.missing.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" missing deps for {pkg_name}: {} — skipping update",
|
" {}",
|
||||||
|
ui::fail(&format!(
|
||||||
|
"missing deps for {pkg_name}: {} — skipping update",
|
||||||
rep.missing.join(", ")
|
rep.missing.join(", ")
|
||||||
|
))
|
||||||
);
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
print_dry_run_plan(latest, Some(installed));
|
print_dry_run_plan(latest);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) {
|
if let Err(e) = install::install_package(
|
||||||
eprintln!(" failed to update {pkg_name}: {e}");
|
latest,
|
||||||
|
bin_dir,
|
||||||
|
track,
|
||||||
|
Some(installed),
|
||||||
|
no_hooks,
|
||||||
|
assume_yes,
|
||||||
|
) {
|
||||||
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
||||||
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
} else {
|
} else {
|
||||||
updated += 1;
|
updated += 1;
|
||||||
|
|
@ -410,12 +507,14 @@ fn cmd_update(
|
||||||
// Only for --all: a single named update already makes its own outcome
|
// 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.
|
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
|
||||||
if all {
|
if all {
|
||||||
let mut parts = Vec::new();
|
let updated_s = format!("{updated} updated");
|
||||||
|
let unchanged_s = format!("{unchanged} already current");
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
if updated > 0 {
|
if updated > 0 {
|
||||||
parts.push(format!("{updated} updated"));
|
parts.push(&updated_s);
|
||||||
}
|
}
|
||||||
parts.push(format!("{unchanged} already up to date"));
|
parts.push(&unchanged_s);
|
||||||
println!("{}", ui::style(&parts.join(", "), ui::BOLD));
|
ui::summary(&parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if any_failed {
|
if any_failed {
|
||||||
|
|
@ -427,7 +526,12 @@ fn cmd_update(
|
||||||
/// Whether `pkg_name` should be updated: always true on a track switch
|
/// Whether `pkg_name` should be updated: always true on a track switch
|
||||||
/// (an explicit user action that must take effect regardless of version
|
/// (an explicit user action that must take effect regardless of version
|
||||||
/// ordering), otherwise a real semver comparison via [`is_newer`].
|
/// 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 {
|
fn should_update(
|
||||||
|
installed_version: &str,
|
||||||
|
installed_track: Track,
|
||||||
|
active_track: Track,
|
||||||
|
latest_version: &str,
|
||||||
|
) -> bool {
|
||||||
if installed_track != active_track {
|
if installed_track != active_track {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +545,10 @@ fn should_update(installed_version: &str, installed_track: Track, active_track:
|
||||||
/// (with a warning) for any version string that isn't valid semver, rather
|
/// (with a warning) for any version string that isn't valid semver, rather
|
||||||
/// than hard-erroring on packages built before this convention existed.
|
/// than hard-erroring on packages built before this convention existed.
|
||||||
fn is_newer(installed: &str, latest: &str) -> bool {
|
fn is_newer(installed: &str, latest: &str) -> bool {
|
||||||
match (semver::Version::parse(installed), semver::Version::parse(latest)) {
|
match (
|
||||||
|
semver::Version::parse(installed),
|
||||||
|
semver::Version::parse(latest),
|
||||||
|
) {
|
||||||
(Ok(i), Ok(l)) => l > i,
|
(Ok(i), Ok(l)) => l > i,
|
||||||
_ => {
|
_ => {
|
||||||
if installed != latest {
|
if installed != latest {
|
||||||
|
|
@ -455,15 +562,14 @@ fn is_newer(installed: &str, latest: &str) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prints one index entry in the shared `list`/`search` format: name,
|
fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow {
|
||||||
/// version, description, and an `[installed <version>]` tag when applicable.
|
ui::CatalogRow {
|
||||||
fn print_index_entry(pkg: &manifest::Package, state: &state::State) {
|
name: pkg.name.clone(),
|
||||||
let tag = if state.is_installed(&pkg.name) {
|
version: pkg.version.clone(),
|
||||||
ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN)
|
installed: state.is_installed(&pkg.name),
|
||||||
} else {
|
detail: pkg.description.clone(),
|
||||||
String::new()
|
aside: String::new(),
|
||||||
};
|
}
|
||||||
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
|
|
@ -471,24 +577,44 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
|
|
||||||
if installed_only {
|
if installed_only {
|
||||||
if state.packages.is_empty() {
|
if state.packages.is_empty() {
|
||||||
println!("no packages installed");
|
println!("{}", ui::dim("no packages installed"));
|
||||||
}
|
|
||||||
for pkg in state.packages.values() {
|
|
||||||
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
|
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let mut pkgs: Vec<_> = state.packages.values().collect();
|
||||||
if !matches!(track, Track::Stable) {
|
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
println!("tracking:{}\n", ui::track_badge(track));
|
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 index = manifest::load(false, track)?;
|
||||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
for name in names {
|
let installed = names.iter().filter(|n| state.is_installed(n)).count();
|
||||||
print_index_entry(&index.packages[name], &state);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,13 +639,19 @@ fn cmd_search(query: &str, track: Track) -> Result<()> {
|
||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
println!("no packages matched '{query}'");
|
println!("{}", ui::dim(&format!("no packages matched '{query}'")));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in names {
|
ui::heading(
|
||||||
print_index_entry(&index.packages[name], &state);
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,41 +663,45 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
|
||||||
|
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
let status = if let Some(inst) = state.packages.get(name) {
|
let status = if let Some(inst) = state.packages.get(name) {
|
||||||
ui::style(&format!("installed ({})", inst.version), ui::GREEN)
|
ui::style(&format!("installed {}", inst.version), ui::GREEN)
|
||||||
} else {
|
} else {
|
||||||
ui::style("not installed", ui::DIM)
|
ui::style("not installed", ui::DIM)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
|
let track_part = ui::track_tag(track);
|
||||||
println!(" {}", pkg.description);
|
ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]);
|
||||||
println!(" status: {status}");
|
println!(" {}\n", pkg.description);
|
||||||
println!(
|
ui::kv("status", &status);
|
||||||
" binaries: {}",
|
ui::kv(
|
||||||
pkg.binaries
|
"binaries",
|
||||||
|
&pkg.binaries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.name.as_str())
|
.map(|b| b.name.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", "),
|
||||||
);
|
);
|
||||||
if !pkg.system_deps.is_empty() {
|
if !pkg.system_deps.is_empty() {
|
||||||
println!(" system deps: {}", pkg.system_deps.join(", "));
|
ui::kv("system deps", &pkg.system_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.optional_system_deps.is_empty() {
|
if !pkg.optional_system_deps.is_empty() {
|
||||||
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
|
ui::kv("optional", &pkg.optional_system_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.bread_deps.is_empty() {
|
if !pkg.bread_deps.is_empty() {
|
||||||
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
ui::kv("bread deps", &pkg.bread_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.services.is_empty() {
|
if !pkg.services.is_empty() {
|
||||||
println!(
|
ui::kv(
|
||||||
" services: {}",
|
"services",
|
||||||
pkg.services
|
&pkg.services
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.unit.as_str())
|
.map(|s| s.unit.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", "),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(inst) = state.packages.get(name) {
|
||||||
|
ui::kv("installed", &ui::short_date(&inst.installed_at));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,18 +720,33 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("no packages installed — nothing to check");
|
println!("{}", ui::dim("no packages installed — nothing to check"));
|
||||||
return Ok(());
|
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;
|
let mut all_ok = true;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
if let Some(pkg) = index.get(pkg_name) {
|
if let Some(pkg) = index.get(pkg_name) {
|
||||||
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
|
if !doctor::report(
|
||||||
|
pkg_name,
|
||||||
|
&pkg.system_deps,
|
||||||
|
&pkg.optional_system_deps,
|
||||||
|
name_w,
|
||||||
|
) {
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
|
ui::check_row(
|
||||||
|
false,
|
||||||
|
pkg_name,
|
||||||
|
name_w,
|
||||||
|
"not found in index (removed from registry?)",
|
||||||
|
);
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,7 +772,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
||||||
}
|
}
|
||||||
|
|
||||||
if all_ok {
|
if all_ok {
|
||||||
println!("{}", ui::ok("all checks passed"));
|
ui::summary(&[&ui::ok("all checks passed")]);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -674,38 +825,47 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("no packages installed — nothing to verify");
|
println!("{}", ui::dim("no packages installed — nothing to verify"));
|
||||||
return Ok(());
|
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;
|
let mut any_bad = false;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
let installed = &state.packages[pkg_name];
|
let installed = &state.packages[pkg_name];
|
||||||
if installed.binary_sha256.is_empty() {
|
if installed.binary_sha256.is_empty() {
|
||||||
println!(
|
ui::unknown_row(
|
||||||
" {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)",
|
pkg_name,
|
||||||
ui::style("?", ui::DIM)
|
name_w,
|
||||||
|
"no recorded checksums (installed before verify support)",
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
||||||
VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))),
|
VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin),
|
||||||
VerifyStatus::Missing => {
|
VerifyStatus::Missing => {
|
||||||
eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING")));
|
ui::check_row(false, pkg_name, name_w, &format!("{bin} missing"));
|
||||||
any_bad = true;
|
any_bad = true;
|
||||||
}
|
}
|
||||||
VerifyStatus::Tampered => {
|
VerifyStatus::Tampered => {
|
||||||
eprintln!(
|
ui::check_row(
|
||||||
" {}",
|
false,
|
||||||
ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)"))
|
pkg_name,
|
||||||
|
name_w,
|
||||||
|
&format!("{bin} tampered (checksum mismatch)"),
|
||||||
);
|
);
|
||||||
any_bad = true;
|
any_bad = true;
|
||||||
}
|
}
|
||||||
VerifyStatus::Unknown => {
|
VerifyStatus::Unknown => {
|
||||||
println!(
|
ui::unknown_row(
|
||||||
" {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)",
|
pkg_name,
|
||||||
ui::style("?", ui::DIM)
|
name_w,
|
||||||
|
&format!("{bin} no recorded checksum for this binary"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -715,7 +875,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
if any_bad {
|
if any_bad {
|
||||||
bail!("verification failed for one or more binaries");
|
bail!("verification failed for one or more binaries");
|
||||||
}
|
}
|
||||||
println!("{}", ui::ok("all recorded checksums match"));
|
ui::summary(&[&ui::ok("all recorded checksums match")]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -728,12 +888,19 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
/// why rollback is backup-based rather than a network re-pin in the first
|
/// 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
|
/// place. Pure with respect to global state (caller supplies both dirs), so
|
||||||
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
/// 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>> {
|
fn restore_binaries(
|
||||||
|
backup_dir: &Path,
|
||||||
|
binaries: &[String],
|
||||||
|
bin_dir: &Path,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
let mut sha256 = HashMap::new();
|
let mut sha256 = HashMap::new();
|
||||||
for bin in binaries {
|
for bin in binaries {
|
||||||
let backup_path = backup_dir.join(bin);
|
let backup_path = backup_dir.join(bin);
|
||||||
if !backup_path.exists() {
|
if !backup_path.exists() {
|
||||||
bail!("backup for binary '{bin}' is missing at {}", backup_path.display());
|
bail!(
|
||||||
|
"backup for binary '{bin}' is missing at {}",
|
||||||
|
backup_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(&backup_path)
|
let bytes = std::fs::read(&backup_path)
|
||||||
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
||||||
|
|
@ -767,6 +934,8 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
|
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);
|
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
||||||
if !backup_dir.exists() {
|
if !backup_dir.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
|
|
@ -794,7 +963,10 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}"))
|
ui::ok(&format!(
|
||||||
|
"rolled back {pkg_name} {} → {target_version}",
|
||||||
|
ui::dim(&from_version)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -838,18 +1010,33 @@ mod tests {
|
||||||
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
||||||
// "bakery track set stable && bakery update --all" from beta must
|
// "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.
|
// 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"));
|
assert!(should_update(
|
||||||
|
"0.4.0-beta",
|
||||||
|
Track::Beta,
|
||||||
|
Track::Stable,
|
||||||
|
"0.3.0"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_update_false_when_same_track_and_not_newer() {
|
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.1",
|
||||||
|
Track::Stable,
|
||||||
|
Track::Stable,
|
||||||
|
"0.3.1"
|
||||||
|
));
|
||||||
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_update_true_when_same_track_and_newer() {
|
fn should_update_true_when_same_track_and_newer() {
|
||||||
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
|
assert!(should_update(
|
||||||
|
"0.3.1",
|
||||||
|
Track::Stable,
|
||||||
|
Track::Stable,
|
||||||
|
"0.3.2"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -902,7 +1089,10 @@ mod tests {
|
||||||
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
||||||
let mut packages = std::collections::HashMap::new();
|
let mut packages = std::collections::HashMap::new();
|
||||||
packages.insert(name.to_string(), pkg);
|
packages.insert(name.to_string(), pkg);
|
||||||
let index = manifest::Index { version: "1".to_string(), packages };
|
let index = manifest::Index {
|
||||||
|
version: "1".to_string(),
|
||||||
|
packages,
|
||||||
|
};
|
||||||
|
|
||||||
let bin_dir = tempdir().unwrap();
|
let bin_dir = tempdir().unwrap();
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
|
|
@ -926,7 +1116,10 @@ mod tests {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
||||||
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
||||||
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok);
|
assert_eq!(
|
||||||
|
verify_binary(dir.path(), "mypkg", Some(&hash)),
|
||||||
|
VerifyStatus::Ok
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -934,21 +1127,30 @@ mod tests {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
||||||
let wrong_hash = "0".repeat(64);
|
let wrong_hash = "0".repeat(64);
|
||||||
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered);
|
assert_eq!(
|
||||||
|
verify_binary(dir.path(), "mypkg", Some(&wrong_hash)),
|
||||||
|
VerifyStatus::Tampered
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_binary_missing_when_file_absent() {
|
fn verify_binary_missing_when_file_absent() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let hash = "0".repeat(64);
|
let hash = "0".repeat(64);
|
||||||
assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing);
|
assert_eq!(
|
||||||
|
verify_binary(dir.path(), "nope", Some(&hash)),
|
||||||
|
VerifyStatus::Missing
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_binary_unknown_when_no_recorded_hash() {
|
fn verify_binary_unknown_when_no_recorded_hash() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
||||||
assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown);
|
assert_eq!(
|
||||||
|
verify_binary(dir.path(), "mypkg", None),
|
||||||
|
VerifyStatus::Unknown
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -962,8 +1164,14 @@ mod tests {
|
||||||
|
|
||||||
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &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!(
|
||||||
assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes")));
|
fs::read(bin_dir.join("mypkg")).unwrap(),
|
||||||
|
b"old version bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hashes["mypkg"],
|
||||||
|
hex::encode(Sha256::digest(b"old version bytes"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
|
||||||
/// exercise the verification logic with a throwaway keypair instead of the
|
/// exercise the verification logic with a throwaway keypair instead of the
|
||||||
/// real production key.
|
/// real production key.
|
||||||
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
|
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
|
||||||
let public_key =
|
let public_key = PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
||||||
PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
|
||||||
let signature =
|
let signature =
|
||||||
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
|
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
|
||||||
public_key
|
public_key
|
||||||
|
|
@ -183,9 +182,7 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||||
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
||||||
Ok(index) => return Ok(index),
|
Ok(index) => return Ok(index),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!(
|
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
|
||||||
" warning: cached index.json failed verification ({err}), re-fetching…"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,17 +208,12 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_and_verify_cache(
|
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result<Index> {
|
||||||
cache_path: &Path,
|
|
||||||
sig_cache_path: &Path,
|
|
||||||
track: Track,
|
|
||||||
) -> Result<Index> {
|
|
||||||
let bytes = std::fs::read(cache_path).context("reading cached index")?;
|
let bytes = std::fs::read(cache_path).context("reading cached index")?;
|
||||||
let sig_text = std::fs::read_to_string(sig_cache_path)
|
let sig_text = std::fs::read_to_string(sig_cache_path)
|
||||||
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
||||||
verify_index_signature(&bytes, &sig_text).with_context(|| {
|
verify_index_signature(&bytes, &sig_text)
|
||||||
format!("cached {track} index failed signature verification")
|
.with_context(|| format!("cached {track} index failed signature verification"))?;
|
||||||
})?;
|
|
||||||
serde_json::from_slice(&bytes).context("parsing cached index")
|
serde_json::from_slice(&bytes).context("parsing cached index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,8 +277,10 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
||||||
Ok(bytes) => Ok(bytes),
|
Ok(bytes) => Ok(bytes),
|
||||||
Err(primary_err) => {
|
Err(primary_err) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" primary URL failed ({}), trying GitHub fallback…",
|
" {}",
|
||||||
primary_err
|
crate::ui::note(&format!(
|
||||||
|
"primary URL failed ({primary_err}), trying GitHub fallback…"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
||||||
}
|
}
|
||||||
|
|
@ -305,9 +299,7 @@ const CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
use std::io::{IsTerminal, Read};
|
use std::io::{IsTerminal, Read};
|
||||||
let resp = ureq::get(url)
|
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
.call()
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status != 200 {
|
if status != 200 {
|
||||||
bail!("HTTP {status} from {url}");
|
bail!("HTTP {status} from {url}");
|
||||||
|
|
@ -320,7 +312,11 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
// is what makes printing partway through the download possible, without
|
// is what makes printing partway through the download possible, without
|
||||||
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
||||||
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
||||||
let show_progress = content_length.is_some() && std::io::stderr().is_terminal();
|
// Progress is reassurance for multi-MB binaries. A 4 KB index fetch
|
||||||
|
// drawing a 100% / 0.0 MB bar is noise, not feedback.
|
||||||
|
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
|
||||||
|
let show_progress =
|
||||||
|
content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal();
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut reader = resp.into_reader();
|
let mut reader = resp.into_reader();
|
||||||
|
|
@ -336,27 +332,17 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
||||||
}
|
}
|
||||||
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
||||||
print_progress(buf.len() as u64, content_length.unwrap());
|
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||||
last_print = std::time::Instant::now();
|
last_print = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if show_progress {
|
if show_progress {
|
||||||
print_progress(buf.len() as u64, content_length.unwrap());
|
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||||
eprintln!();
|
crate::ui::finish_progress();
|
||||||
}
|
}
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_progress(downloaded: u64, total: u64) {
|
|
||||||
use std::io::Write;
|
|
||||||
eprint!(
|
|
||||||
"\r ⇣ {:.1}/{:.1} MB",
|
|
||||||
downloaded as f64 / 1_048_576.0,
|
|
||||||
total as f64 / 1_048_576.0
|
|
||||||
);
|
|
||||||
let _ = std::io::stderr().flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -408,10 +394,7 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
||||||
fn stable_cache_path_matches_pre_track_filename() {
|
fn stable_cache_path_matches_pre_track_filename() {
|
||||||
// Must stay exactly "index.json" so an existing warm cache from a
|
// Must stay exactly "index.json" so an existing warm cache from a
|
||||||
// pre-track bakery binary is still used after an upgrade.
|
// pre-track bakery binary is still used after an upgrade.
|
||||||
assert_eq!(
|
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
|
||||||
cache_path(Track::Stable).file_name().unwrap(),
|
|
||||||
"index.json"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -428,13 +411,22 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stable_url_has_no_track_prefix() {
|
fn stable_url_has_no_track_prefix() {
|
||||||
assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
|
assert_eq!(
|
||||||
|
primary_url(Track::Stable),
|
||||||
|
format!("{}/index.json", base_url())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn beta_and_dev_urls_are_track_prefixed() {
|
fn beta_and_dev_urls_are_track_prefixed() {
|
||||||
assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url()));
|
assert_eq!(
|
||||||
assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url()));
|
primary_url(Track::Beta),
|
||||||
|
format!("{}/beta/index.json", base_url())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
primary_url(Track::Dev),
|
||||||
|
format!("{}/dev/index.json", base_url())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_package_json() -> &'static str {
|
fn minimal_package_json() -> &'static str {
|
||||||
|
|
|
||||||
385
bakery/src/ui.rs
385
bakery/src/ui.rs
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use std::io::IsTerminal;
|
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
|
||||||
pub const RESET: &str = "\x1b[0m";
|
pub const RESET: &str = "\x1b[0m";
|
||||||
pub const BOLD: &str = "\x1b[1m";
|
pub const BOLD: &str = "\x1b[1m";
|
||||||
|
|
@ -9,6 +10,19 @@ pub const GREEN: &str = "\x1b[32m";
|
||||||
pub const YELLOW: &str = "\x1b[33m";
|
pub const YELLOW: &str = "\x1b[33m";
|
||||||
pub const CYAN: &str = "\x1b[36m";
|
pub const CYAN: &str = "\x1b[36m";
|
||||||
pub const MAGENTA: &str = "\x1b[35m";
|
pub const MAGENTA: &str = "\x1b[35m";
|
||||||
|
pub const BOLD_CYAN: &str = "\x1b[1;36m";
|
||||||
|
|
||||||
|
/// Clap help styling — same cyan headers / green literals / dim placeholders
|
||||||
|
/// as the rest of bakery, so `bakery --help` doesn't look like a different
|
||||||
|
/// program from `bakery list`.
|
||||||
|
pub const CLAP_STYLES: Styles = Styles::styled()
|
||||||
|
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||||
|
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||||
|
.literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||||
|
.placeholder(AnsiColor::BrightBlack.on_default())
|
||||||
|
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
|
||||||
|
.valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||||
|
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
|
||||||
|
|
||||||
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
||||||
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
||||||
|
|
@ -18,21 +32,52 @@ pub fn colors_enabled() -> bool {
|
||||||
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn colors_enabled_err() -> bool {
|
||||||
|
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn style(s: &str, code: &str) -> String {
|
pub fn style(s: &str, code: &str) -> String {
|
||||||
if colors_enabled() {
|
paint(s, code, colors_enabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style_err(s: &str, code: &str) -> String {
|
||||||
|
paint(s, code, colors_enabled_err())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(s: &str, code: &str, on: bool) -> String {
|
||||||
|
if on {
|
||||||
format!("{code}{s}{RESET}")
|
format!("{code}{s}{RESET}")
|
||||||
} else {
|
} else {
|
||||||
s.to_string()
|
s.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn bold(s: &str) -> String {
|
||||||
|
style(s, BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dim(s: &str) -> String {
|
||||||
|
style(s, DIM)
|
||||||
|
}
|
||||||
|
|
||||||
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
||||||
/// common-case output is unchanged.
|
/// common-case output is unchanged.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn track_badge(track: Track) -> String {
|
pub fn track_badge(track: Track) -> String {
|
||||||
|
let tag = track_tag(track);
|
||||||
|
if tag.is_empty() {
|
||||||
|
tag
|
||||||
|
} else {
|
||||||
|
format!(" {tag}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[beta]` / `[dev]` with no leading space; empty for `Stable`.
|
||||||
|
pub fn track_tag(track: Track) -> String {
|
||||||
match track {
|
match track {
|
||||||
Track::Stable => String::new(),
|
Track::Stable => String::new(),
|
||||||
Track::Beta => format!(" {}", style("[beta]", YELLOW)),
|
Track::Beta => style("[beta]", YELLOW),
|
||||||
Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
|
Track::Dev => style("[dev]", MAGENTA),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +99,278 @@ pub fn unchanged(s: &str) -> String {
|
||||||
style(&format!("· {s}"), DIM)
|
style(&format!("· {s}"), DIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn warn(s: &str) -> String {
|
||||||
|
style(&format!("warning: {s}"), YELLOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn note(s: &str) -> String {
|
||||||
|
style(&format!("note: {s}"), DIM)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cyan verb + bold name + dim version — the install/update/remove banner.
|
||||||
|
pub fn action(verb: &str, name: &str, version: Option<&str>) {
|
||||||
|
let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD));
|
||||||
|
if let Some(v) = version {
|
||||||
|
line.push_str(" ");
|
||||||
|
line.push_str(&style(v, DIM));
|
||||||
|
}
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section title plus dim meta (`Packages 16 · 15 installed`).
|
||||||
|
pub fn heading(title: &str, parts: &[&str]) {
|
||||||
|
let mut line = style(title, BOLD_CYAN);
|
||||||
|
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||||
|
for (i, part) in visible.iter().enumerate() {
|
||||||
|
line.push_str(" ");
|
||||||
|
if i > 0 {
|
||||||
|
line.push_str(&style("·", DIM));
|
||||||
|
line.push_str(" ");
|
||||||
|
}
|
||||||
|
line.push_str(part);
|
||||||
|
}
|
||||||
|
println!("{line}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(parts: &[&str]) {
|
||||||
|
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||||
|
if visible.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("{}", style(&visible.join(" · "), BOLD));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left-aligned verb column so install chatter (`downloading` / `placed` /
|
||||||
|
/// `unit`) lines up instead of drifting with the verb length.
|
||||||
|
pub fn step(verb: &str, detail: &str) {
|
||||||
|
println!(" {:<12} {}", dim(verb), detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kv(key: &str, value: &str) {
|
||||||
|
println!(" {:<12} {}", dim(key), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) {
|
||||||
|
let glyph = if ok_flag {
|
||||||
|
style("✓", GREEN)
|
||||||
|
} else {
|
||||||
|
style("✗", RED)
|
||||||
|
};
|
||||||
|
println!(" {glyph} {:<name_width$} {message}", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unknown_row(name: &str, name_width: usize, message: &str) {
|
||||||
|
println!(
|
||||||
|
" {} {:<name_width$} {}",
|
||||||
|
style("?", DIM),
|
||||||
|
name,
|
||||||
|
dim(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CatalogRow {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub installed: bool,
|
||||||
|
/// Wrapped onto following lines (descriptions).
|
||||||
|
pub detail: String,
|
||||||
|
/// Same-line suffix after the version (short dates). Empty for catalog
|
||||||
|
/// views that already use `detail`.
|
||||||
|
pub aside: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-line catalog: status glyph + aligned name/version, then a hanging
|
||||||
|
/// description (or date) wrapped to the terminal width. Column widths are
|
||||||
|
/// computed from the row set so long `-dev.` versions no longer smash the
|
||||||
|
/// old `{: <10}` pad.
|
||||||
|
pub fn print_catalog(rows: &[CatalogRow]) {
|
||||||
|
for line in format_catalog(rows, term_width()) {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_catalog(rows: &[CatalogRow], width: usize) -> Vec<String> {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
|
||||||
|
let indent = 5; // " ✓ " / " "
|
||||||
|
let detail_width = width.saturating_sub(indent).max(24);
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let glyph = if row.installed {
|
||||||
|
style("✓", GREEN)
|
||||||
|
} else {
|
||||||
|
" ".to_string()
|
||||||
|
};
|
||||||
|
let name = style(&format!("{:<name_w$}", row.name), BOLD);
|
||||||
|
let version = style(&row.version, DIM);
|
||||||
|
let mut line = format!(" {glyph} {name} {version}");
|
||||||
|
if !row.aside.is_empty() {
|
||||||
|
line.push_str(" ");
|
||||||
|
line.push_str(&dim(&row.aside));
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
if !row.detail.is_empty() {
|
||||||
|
for wrapped in wrap_words(&row.detail, detail_width) {
|
||||||
|
lines.push(format!(" {}", dim(&wrapped)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||||
|
if width == 0 {
|
||||||
|
return vec![text.to_string()];
|
||||||
|
}
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
if cur.is_empty() {
|
||||||
|
cur = word.to_string();
|
||||||
|
} else if cur.len() + 1 + word.len() <= width {
|
||||||
|
cur.push(' ');
|
||||||
|
cur.push_str(word);
|
||||||
|
} else {
|
||||||
|
lines.push(std::mem::take(&mut cur));
|
||||||
|
cur = word.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
lines.push(cur);
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn short_date(rfc3339: &str) -> String {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|_| rfc3339.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name_width<S: AsRef<str>>(names: impl IntoIterator<Item = S>) -> usize {
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.as_ref().len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `\r`-overwritten download bar on stderr. Pads to a stable width so a
|
||||||
|
/// shorter later frame doesn't leave leftover characters from a longer one.
|
||||||
|
pub fn print_progress(downloaded: u64, total: u64) {
|
||||||
|
let width = term_width().clamp(40, 72);
|
||||||
|
let line = progress_line(downloaded, total, 20);
|
||||||
|
let padded = fit_width(&line, width);
|
||||||
|
eprint!("\r{padded}");
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_progress() {
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String {
|
||||||
|
let dl = downloaded as f64 / 1_048_576.0;
|
||||||
|
let tot = total as f64 / 1_048_576.0;
|
||||||
|
let frac = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(downloaded as f64 / total as f64).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let filled = ((bar_width as f64) * frac).round() as usize;
|
||||||
|
let filled = filled.min(bar_width);
|
||||||
|
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled));
|
||||||
|
let pct = (frac * 100.0).round() as u32;
|
||||||
|
format!(
|
||||||
|
" ⇣ {} {:>3}% {:.1}/{:.1} MB",
|
||||||
|
style_err(&bar, CYAN),
|
||||||
|
pct,
|
||||||
|
dl,
|
||||||
|
tot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fit_width(s: &str, width: usize) -> String {
|
||||||
|
let visible = visible_len(s);
|
||||||
|
if visible >= width {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
format!("{s}{}", " ".repeat(width - visible))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_len(s: &str) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\u{1b}' {
|
||||||
|
if chars.peek() == Some(&'[') {
|
||||||
|
chars.next();
|
||||||
|
for next in chars.by_ref() {
|
||||||
|
if next.is_ascii_alphabetic() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn term_width() -> usize {
|
||||||
|
if let Ok(w) = std::env::var("COLUMNS") {
|
||||||
|
if let Ok(n) = w.parse::<usize>() {
|
||||||
|
if n >= 40 {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ioctl_width().filter(|&n| n >= 40).unwrap_or(80)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn ioctl_width() -> Option<usize> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct WinSize {
|
||||||
|
row: u16,
|
||||||
|
col: u16,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ws = WinSize {
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
// TIOCGWINSZ on Linux.
|
||||||
|
let fd = std::io::stdout().as_raw_fd();
|
||||||
|
let ret = unsafe { ioctl(fd, 0x5413, &mut ws) };
|
||||||
|
if ret == 0 && ws.col > 0 {
|
||||||
|
Some(ws.col as usize)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn ioctl_width() -> Option<usize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -77,4 +394,64 @@ mod tests {
|
||||||
assert!(!ok("foo").contains('·'));
|
assert!(!ok("foo").contains('·'));
|
||||||
assert!(!fail("foo").contains('·'));
|
assert!(!fail("foo").contains('·'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_aligns_names_and_versions() {
|
||||||
|
let lines = format_catalog(
|
||||||
|
&[
|
||||||
|
CatalogRow {
|
||||||
|
name: "bakery".into(),
|
||||||
|
version: "0.7.2-dev.20260815142350+30517f1".into(),
|
||||||
|
installed: true,
|
||||||
|
detail: "Package manager".into(),
|
||||||
|
aside: String::new(),
|
||||||
|
},
|
||||||
|
CatalogRow {
|
||||||
|
name: "breadarr".into(),
|
||||||
|
version: "0.1.2".into(),
|
||||||
|
installed: false,
|
||||||
|
detail: "Homelab arr stack".into(),
|
||||||
|
aside: String::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert!(lines[0].contains("bakery"));
|
||||||
|
assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1"));
|
||||||
|
assert!(lines[1].contains("Package manager"));
|
||||||
|
// Shorter version is padded so the columns stay a block, not a
|
||||||
|
// ragged list — the long bakery version used to overflow `{: <10}`.
|
||||||
|
// Compare display columns, not byte offsets: the installed glyph
|
||||||
|
// is a 3-byte checkmark sitting in a 1-column slot.
|
||||||
|
let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]);
|
||||||
|
let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]);
|
||||||
|
assert_eq!(bakery_col, breadarr_col);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_words_breaks_on_width() {
|
||||||
|
let lines = wrap_words("one two three four", 9);
|
||||||
|
assert_eq!(lines, vec!["one two", "three", "four"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_line_has_bar_and_percent() {
|
||||||
|
let line = progress_line(1_048_576, 2_097_152, 10);
|
||||||
|
assert!(line.contains('█'));
|
||||||
|
assert!(line.contains('░'));
|
||||||
|
assert!(line.contains("50%"));
|
||||||
|
assert!(line.contains("1.0/2.0 MB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_len_ignores_ansi() {
|
||||||
|
assert_eq!(visible_len("hello"), 5);
|
||||||
|
assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_date_from_rfc3339() {
|
||||||
|
assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue