Init commit
This commit is contained in:
commit
6c5536733f
19 changed files with 3312 additions and 0 deletions
46
.github/workflows/release.yml
vendored
Normal file
46
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
DL_DIR: /srv/breadway-dl
|
||||||
|
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="${DL_DIR}/bakery/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
|
||||||
|
cp target/release/bakery "${PKG_DIR}/bakery-x86_64"
|
||||||
|
strip "${PKG_DIR}/bakery-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/bakery-x86_64.sha256"
|
||||||
|
|
||||||
|
# Update the 'latest' symlink.
|
||||||
|
ln -sfn "${PKG_DIR}" "${DL_DIR}/bakery/latest"
|
||||||
|
|
||||||
|
- name: regenerate index.json
|
||||||
|
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="${DL_DIR}/bakery/${VERSION}"
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" \
|
||||||
|
"${PKG_DIR}/bakery-x86_64" \
|
||||||
|
"${PKG_DIR}/bakery-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target/
|
||||||
1843
Cargo.lock
generated
Normal file
1843
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["bakery", "bread-theme"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5"
|
||||||
|
ureq = { version = "2", features = ["json"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
20
bakery/Cargo.toml
Normal file
20
bakery/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "bakery"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Package manager for the bread ecosystem"
|
||||||
|
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
ureq = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
72
bakery/src/doctor.rs
Normal file
72
bakery/src/doctor.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Check whether a list of system dependencies are present.
|
||||||
|
/// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
|
||||||
|
pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for dep in deps {
|
||||||
|
if !dep_present(dep) {
|
||||||
|
missing.push(dep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dep_present(dep: &str) -> bool {
|
||||||
|
// Try `which` first (covers executables like `iw`, `nmcli`).
|
||||||
|
if which(dep) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
|
||||||
|
pkg_config_exists(dep)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn which(bin: &str) -> bool {
|
||||||
|
Command::new("which")
|
||||||
|
.arg(bin)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pkg_config_exists(lib: &str) -> bool {
|
||||||
|
// Arch package names map directly to pkg-config names for GTK libs.
|
||||||
|
Command::new("pkg-config")
|
||||||
|
.arg("--exists")
|
||||||
|
.arg(lib)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a formatted doctor report for a list of system deps.
|
||||||
|
/// Returns true if all deps are satisfied.
|
||||||
|
pub fn report(package_name: &str, deps: &[String]) -> bool {
|
||||||
|
if deps.is_empty() {
|
||||||
|
println!(" {package_name}: no system deps required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match check_deps(deps) {
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" error running doctor: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(missing) => {
|
||||||
|
if missing.is_empty() {
|
||||||
|
println!(" {package_name}: all system deps satisfied");
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
" {package_name}: missing system deps: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" install with: sudo pacman -S {}",
|
||||||
|
missing.join(" ")
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
bakery/src/download.rs
Normal file
47
bakery/src/download.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::manifest::{fetch_binary, Binary};
|
||||||
|
|
||||||
|
/// Download a binary to a temp path, verify its SHA-256, then atomically move
|
||||||
|
/// it into place. Bails before touching `dest` if the checksum fails.
|
||||||
|
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
||||||
|
println!(" downloading {}…", binary.name);
|
||||||
|
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||||
|
.with_context(|| format!("downloading {}", binary.name))?;
|
||||||
|
|
||||||
|
verify_sha256(&bytes, &binary.sha256)
|
||||||
|
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||||
|
|
||||||
|
if let Some(dir) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp = dest.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, &bytes).context("writing binary to tmp")?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::rename(&tmp, dest).context("placing binary")?;
|
||||||
|
println!(" installed {}", dest.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
let actual = hex::encode(hasher.finalize());
|
||||||
|
if actual != expected_hex {
|
||||||
|
bail!(
|
||||||
|
"SHA-256 mismatch\n expected: {}\n actual: {}",
|
||||||
|
expected_hex,
|
||||||
|
actual
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
253
bakery/src/install.rs
Normal file
253
bakery/src/install.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::download::fetch_and_place;
|
||||||
|
use crate::manifest::{Package, Service};
|
||||||
|
use crate::state::{InstalledPackage, State};
|
||||||
|
|
||||||
|
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
|
||||||
|
println!("installing {}@{}…", pkg.name, pkg.version);
|
||||||
|
|
||||||
|
// 1. Download and verify all binaries.
|
||||||
|
let mut binary_names = Vec::new();
|
||||||
|
for bin in &pkg.binaries {
|
||||||
|
let dest = bin_dir.join(&bin.name);
|
||||||
|
fetch_and_place(bin, &dest)?;
|
||||||
|
binary_names.push(bin.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scaffold config dir + example file.
|
||||||
|
if let Some(cfg) = &pkg.config {
|
||||||
|
scaffold_config(cfg)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Install systemd user units.
|
||||||
|
let mut service_names = Vec::new();
|
||||||
|
for svc in &pkg.services {
|
||||||
|
install_service(svc, bin_dir)?;
|
||||||
|
service_names.push(svc.unit.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Run post_install hooks.
|
||||||
|
for cmd in &pkg.post_install {
|
||||||
|
run_hook(cmd, &pkg.name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Record in state.
|
||||||
|
let mut state = State::load()?;
|
||||||
|
state.record(InstalledPackage {
|
||||||
|
name: pkg.name.clone(),
|
||||||
|
version: pkg.version.clone(),
|
||||||
|
binaries: binary_names,
|
||||||
|
services: service_names,
|
||||||
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
println!(" {} installed successfully", pkg.name);
|
||||||
|
warn_path_if_needed(bin_dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
||||||
|
let mut state = State::load()?;
|
||||||
|
let installed = match state.remove(pkg_name) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
eprintln!("{pkg_name} is not installed");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove binaries.
|
||||||
|
for bin in &installed.binaries {
|
||||||
|
let path = bin_dir.join(bin);
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("removing {}", path.display()))?;
|
||||||
|
println!(" removed {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for unit removal.
|
||||||
|
if !installed.services.is_empty() {
|
||||||
|
let service_dir = systemd_user_dir();
|
||||||
|
for unit in &installed.services {
|
||||||
|
let unit_path = service_dir.join(unit);
|
||||||
|
if confirm_remove_unit(unit) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "disable", "--now", unit])
|
||||||
|
.status();
|
||||||
|
if unit_path.exists() {
|
||||||
|
std::fs::remove_file(&unit_path).ok();
|
||||||
|
}
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "daemon-reload"])
|
||||||
|
.status();
|
||||||
|
println!(" removed unit {unit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never touch config or data dirs.
|
||||||
|
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||||
|
if cfg_dir.exists() {
|
||||||
|
println!(" config preserved at {}", cfg_dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join(pkg_name);
|
||||||
|
if data_dir.exists() {
|
||||||
|
println!(" data preserved at {}", data_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.save()?;
|
||||||
|
println!(" {pkg_name} removed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> {
|
||||||
|
let dir = expand_tilde(&cfg.dir);
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
if let Some(example) = &cfg.example {
|
||||||
|
let dest = dir.join(example);
|
||||||
|
if !dest.exists() {
|
||||||
|
// We don't have the actual example file here at install time —
|
||||||
|
// the product repo's release bundle should include it.
|
||||||
|
// For now just note it; release.yml will bundle example configs.
|
||||||
|
println!(" config dir ready at {}", dir.display());
|
||||||
|
println!(
|
||||||
|
" copy your {example} to {} to configure {}",
|
||||||
|
dest.display(),
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(" config at {} already exists, skipping", dest.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> {
|
||||||
|
let service_dir = systemd_user_dir();
|
||||||
|
std::fs::create_dir_all(&service_dir)?;
|
||||||
|
|
||||||
|
let unit_path = service_dir.join(&svc.unit);
|
||||||
|
|
||||||
|
// The unit file is expected to be bundled alongside the binary in the
|
||||||
|
// release artifact (or embedded). For now, patch ExecStart if the unit
|
||||||
|
// already exists (same pattern as bread/scripts/install.sh).
|
||||||
|
if unit_path.exists() {
|
||||||
|
patch_exec_start(&unit_path, bin_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "daemon-reload"])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
if svc.enable {
|
||||||
|
if Command::new("systemctl")
|
||||||
|
.args(["--user", "is-active", "--quiet", &svc.unit])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "restart", &svc.unit])
|
||||||
|
.status();
|
||||||
|
println!(" {} restarted", svc.unit);
|
||||||
|
} else {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "enable", "--now", &svc.unit])
|
||||||
|
.status();
|
||||||
|
println!(" {} enabled and started", svc.unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
|
let text = std::fs::read_to_string(unit_path)?;
|
||||||
|
let patched: String = text
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
if line.trim_start().starts_with("ExecStart=") {
|
||||||
|
// Replace only the path prefix, keep args.
|
||||||
|
let rest = line.splitn(2, '=').nth(1).unwrap_or("");
|
||||||
|
let argv: Vec<&str> = rest.split_whitespace().collect();
|
||||||
|
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
|
||||||
|
let new_path = bin_dir.join(bin_name);
|
||||||
|
let args: Vec<&str> = argv.iter().skip(1).copied().collect();
|
||||||
|
if args.is_empty() {
|
||||||
|
format!("ExecStart={}", new_path.display())
|
||||||
|
} else {
|
||||||
|
format!("ExecStart={} {}", new_path.display(), args.join(" "))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
std::fs::write(unit_path, patched)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
||||||
|
println!(" running post_install hook: {cmd}");
|
||||||
|
let status = Command::new("sh")
|
||||||
|
.args(["-c", cmd])
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!(" warning: hook exited with {status}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_remove_unit(unit: &str) -> bool {
|
||||||
|
use std::io::{self, Write};
|
||||||
|
print!(" remove systemd unit {unit}? [y/N] ");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().read_line(&mut buf).ok();
|
||||||
|
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn systemd_user_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||||
|
.join("systemd/user")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
|
||||||
|
Some(dirs::config_dir()?.join(pkg_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
|
.join(rest)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_path_if_needed(bin_dir: &Path) {
|
||||||
|
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||||
|
let bin_str = bin_dir.to_string_lossy();
|
||||||
|
if !path_var.split(':').any(|p| p == bin_str) {
|
||||||
|
println!(
|
||||||
|
"\n note: {} is not in PATH — add to your shell profile:",
|
||||||
|
bin_str
|
||||||
|
);
|
||||||
|
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
bakery/src/main.rs
Normal file
216
bakery/src/main.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
mod doctor;
|
||||||
|
mod download;
|
||||||
|
mod install;
|
||||||
|
mod manifest;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "bakery", about = "Package manager for the bread ecosystem")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Cmd,
|
||||||
|
/// Override the directory where binaries are installed
|
||||||
|
#[arg(long, env = "BAKERY_BIN_DIR", global = true)]
|
||||||
|
bin_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Install a package
|
||||||
|
Install {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// Remove an installed package (data files are never deleted)
|
||||||
|
Remove {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// Update one or all installed packages
|
||||||
|
Update {
|
||||||
|
/// Package to update; omit to update all installed packages
|
||||||
|
package: Option<String>,
|
||||||
|
},
|
||||||
|
/// List packages
|
||||||
|
List {
|
||||||
|
/// Show only installed packages
|
||||||
|
#[arg(long)]
|
||||||
|
installed: bool,
|
||||||
|
},
|
||||||
|
/// Show details for a package
|
||||||
|
Info {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// Check system dependencies for installed or requested packages
|
||||||
|
Doctor {
|
||||||
|
/// Package to check; omit to check all installed packages
|
||||||
|
package: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bin_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
|
.join(".local/bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Cmd::Install { package } => cmd_install(&package, &bin_dir),
|
||||||
|
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
||||||
|
Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
|
||||||
|
Cmd::List { installed } => cmd_list(installed),
|
||||||
|
Cmd::Info { package } => cmd_info(&package),
|
||||||
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
let index = manifest::load(false)?;
|
||||||
|
let pkg = index
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||||
|
|
||||||
|
// Doctor runs first — bail if system deps are missing.
|
||||||
|
println!("checking system dependencies…");
|
||||||
|
let missing = doctor::check_deps(&pkg.system_deps)?;
|
||||||
|
if !missing.is_empty() {
|
||||||
|
eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
|
||||||
|
eprintln!("install with: sudo pacman -S {}", missing.join(" "));
|
||||||
|
bail!("system deps not satisfied");
|
||||||
|
}
|
||||||
|
|
||||||
|
install::install_package(pkg, bin_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
install::remove_package(name, bin_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
let index = manifest::load(true)?; // force refresh on update
|
||||||
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
let targets: Vec<String> = match name {
|
||||||
|
Some(n) => vec![n.to_string()],
|
||||||
|
None => state.packages.keys().cloned().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for pkg_name in &targets {
|
||||||
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
eprintln!("{pkg_name} is not installed, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let latest = match index.get(pkg_name) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
eprintln!("{pkg_name} not found in index, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if installed.version == latest.version {
|
||||||
|
println!("{pkg_name} is already at {}", installed.version);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"updating {pkg_name} {} → {}",
|
||||||
|
installed.version, latest.version
|
||||||
|
);
|
||||||
|
install::install_package(latest, bin_dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_list(installed_only: bool) -> Result<()> {
|
||||||
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
if installed_only {
|
||||||
|
if state.packages.is_empty() {
|
||||||
|
println!("no packages installed");
|
||||||
|
}
|
||||||
|
for pkg in state.packages.values() {
|
||||||
|
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = manifest::load(false)?;
|
||||||
|
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||||
|
names.sort();
|
||||||
|
for name in names {
|
||||||
|
let pkg = &index.packages[name];
|
||||||
|
let tag = if state.is_installed(name) {
|
||||||
|
format!(" [installed {}]", state.packages[name].version)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_info(name: &str) -> Result<()> {
|
||||||
|
let index = manifest::load(false)?;
|
||||||
|
let pkg = index
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||||
|
|
||||||
|
let state = state::State::load()?;
|
||||||
|
let status = if let Some(inst) = state.packages.get(name) {
|
||||||
|
format!("installed ({})", inst.version)
|
||||||
|
} else {
|
||||||
|
"not installed".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{} {}", pkg.name, pkg.version);
|
||||||
|
println!(" {}", pkg.description);
|
||||||
|
println!(" status: {status}");
|
||||||
|
println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
if !pkg.system_deps.is_empty() {
|
||||||
|
println!(" system deps: {}", pkg.system_deps.join(", "));
|
||||||
|
}
|
||||||
|
if !pkg.bread_deps.is_empty() {
|
||||||
|
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
||||||
|
}
|
||||||
|
if !pkg.services.is_empty() {
|
||||||
|
println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
||||||
|
let index = manifest::load(false)?;
|
||||||
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
let targets: Vec<String> = match name {
|
||||||
|
Some(n) => vec![n.to_string()],
|
||||||
|
None => state.packages.keys().cloned().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if targets.is_empty() {
|
||||||
|
println!("no packages installed — nothing to check");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_ok = true;
|
||||||
|
for pkg_name in &targets {
|
||||||
|
if let Some(pkg) = index.get(pkg_name) {
|
||||||
|
if !doctor::report(pkg_name, &pkg.system_deps) {
|
||||||
|
all_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_ok {
|
||||||
|
println!("all checks passed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
137
bakery/src/manifest.rs
Normal file
137
bakery/src/manifest.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
|
||||||
|
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Binary {
|
||||||
|
pub name: String,
|
||||||
|
pub dl_url: String,
|
||||||
|
pub github_url: String,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Service {
|
||||||
|
pub unit: String,
|
||||||
|
pub enable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ConfigScaffold {
|
||||||
|
pub dir: String,
|
||||||
|
/// relative to the product repo root; copied as-is if absent at install time
|
||||||
|
pub example: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Package {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub version: String,
|
||||||
|
pub binaries: Vec<Binary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub system_deps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bread_deps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub services: Vec<Service>,
|
||||||
|
pub config: Option<ConfigScaffold>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub post_install: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Index {
|
||||||
|
pub version: String,
|
||||||
|
pub packages: std::collections::HashMap<String, Package>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Index {
|
||||||
|
pub fn get(&self, name: &str) -> Option<&Package> {
|
||||||
|
self.packages.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn all(&self) -> impl Iterator<Item = &Package> {
|
||||||
|
self.packages.values()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the manifest, using the on-disk cache when it is fresh enough.
|
||||||
|
/// Always fetches if `force_refresh` is true.
|
||||||
|
pub fn load(force_refresh: bool) -> Result<Index> {
|
||||||
|
let cache_path = cache_path();
|
||||||
|
|
||||||
|
if !force_refresh && cache_is_fresh(&cache_path) {
|
||||||
|
let text = std::fs::read_to_string(&cache_path)
|
||||||
|
.context("reading cached index")?;
|
||||||
|
return serde_json::from_str(&text).context("parsing cached index");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_and_cache(&cache_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_is_fresh(path: &PathBuf) -> bool {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
|
||||||
|
let text = fetch_text(PRIMARY_URL)?;
|
||||||
|
if let Some(dir) = cache_path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
std::fs::write(cache_path, &text)?;
|
||||||
|
serde_json::from_str(&text).context("parsing index.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_text(url: &str) -> Result<String> {
|
||||||
|
ureq::get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||||
|
.into_string()
|
||||||
|
.context("reading response body")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_path() -> PathBuf {
|
||||||
|
dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||||
|
.join("bakery/index.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
|
||||||
|
/// on any network error. Returns the raw bytes.
|
||||||
|
pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
||||||
|
match fetch_bytes(primary_url) {
|
||||||
|
Ok(bytes) => Ok(bytes),
|
||||||
|
Err(primary_err) => {
|
||||||
|
eprintln!(
|
||||||
|
" primary URL failed ({}), trying GitHub fallback…",
|
||||||
|
primary_err
|
||||||
|
);
|
||||||
|
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
|
use std::io::Read;
|
||||||
|
let resp = ureq::get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status != 200 {
|
||||||
|
bail!("HTTP {status} from {url}");
|
||||||
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
resp.into_reader()
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.context("reading binary")?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
60
bakery/src/state.rs
Normal file
60
bakery/src/state.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct InstalledPackage {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub binaries: Vec<String>,
|
||||||
|
pub services: Vec<String>,
|
||||||
|
pub installed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct State {
|
||||||
|
pub packages: HashMap<String, InstalledPackage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = state_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(&path).context("reading installed.json")?;
|
||||||
|
serde_json::from_str(&text).context("parsing installed.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = state_path();
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let text = serde_json::to_string_pretty(self)?;
|
||||||
|
std::fs::write(&path, text).context("writing installed.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_installed(&self, name: &str) -> bool {
|
||||||
|
self.packages.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&mut self, pkg: InstalledPackage) {
|
||||||
|
self.packages.insert(pkg.name.clone(), pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
|
||||||
|
self.packages.remove(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path() -> PathBuf {
|
||||||
|
dirs::state_dir()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
|
.join(".local/state")
|
||||||
|
})
|
||||||
|
.join("bakery/installed.json")
|
||||||
|
}
|
||||||
20
bread-theme/CHANGELOG.md
Normal file
20
bread-theme/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# bread-theme changelog
|
||||||
|
|
||||||
|
## Coordinated bump policy
|
||||||
|
|
||||||
|
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||||
|
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||||
|
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||||
|
release together. Note the impact in this file before tagging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## theme-v0.1.0 (2026-06-06)
|
||||||
|
|
||||||
|
- Initial extraction from `breadpad-shared/src/theme.rs`
|
||||||
|
- `Palette` struct with `color0`–`color7` and Catppuccin Mocha default
|
||||||
|
- `load_palette()` reads `~/.cache/wal/colors.json`, falls back to default
|
||||||
|
- `css_vars(palette)` emits `@define-color` block + font declaration
|
||||||
|
- `hex_to_rgba(hex, alpha)` utility
|
||||||
|
- `tokens` module with spacing scale, border radii, font sizes from `BREAD_DESIGN_SYSTEM.md`
|
||||||
|
- `gtk` feature: `apply_css()` and `apply_user_css()` helpers for GTK4 CSS providers
|
||||||
20
bread-theme/Cargo.toml
Normal file
20
bread-theme/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-theme"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Shared pywal + Catppuccin theming crate for the bread ecosystem"
|
||||||
|
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||||
|
keywords = ["theming", "pywal", "gtk4", "wayland"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
|
||||||
|
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
|
||||||
|
gtk = ["dep:gtk4"]
|
||||||
50
bread-theme/src/gtk.rs
Normal file
50
bread-theme/src/gtk.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use gtk4::CssProvider;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Apply a CSS string to the default display at APPLICATION priority.
|
||||||
|
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
|
||||||
|
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
||||||
|
let display = gtk4::gdk::Display::default().expect("no display");
|
||||||
|
let mut guard = provider.borrow_mut();
|
||||||
|
if let Some(p) = guard.as_ref() {
|
||||||
|
p.load_from_string(css);
|
||||||
|
} else {
|
||||||
|
let p = CssProvider::new();
|
||||||
|
p.load_from_string(css);
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
&display,
|
||||||
|
&p,
|
||||||
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
*guard = Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
||||||
|
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||||
|
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
||||||
|
let display = gtk4::gdk::Display::default().expect("no display");
|
||||||
|
let mut guard = provider.borrow_mut();
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(css) => {
|
||||||
|
if let Some(p) = guard.as_ref() {
|
||||||
|
p.load_from_string(&css);
|
||||||
|
} else {
|
||||||
|
let p = CssProvider::new();
|
||||||
|
p.load_from_string(&css);
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
&display,
|
||||||
|
&p,
|
||||||
|
gtk4::STYLE_PROVIDER_PRIORITY_USER,
|
||||||
|
);
|
||||||
|
*guard = Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
if let Some(p) = guard.as_ref() {
|
||||||
|
p.load_from_string("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
bread-theme/src/lib.rs
Normal file
96
bread-theme/src/lib.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
pub mod palette;
|
||||||
|
#[cfg(feature = "gtk")]
|
||||||
|
pub mod gtk;
|
||||||
|
|
||||||
|
pub use palette::{load_palette, Palette};
|
||||||
|
|
||||||
|
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||||
|
pub mod tokens {
|
||||||
|
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
|
||||||
|
pub const FONT_SIZE_BASE: u8 = 14;
|
||||||
|
pub const FONT_SIZE_SECONDARY: u8 = 12;
|
||||||
|
|
||||||
|
// Spacing scale (px, 4px units)
|
||||||
|
pub const SPACE_XS: u8 = 4;
|
||||||
|
pub const SPACE_SM: u8 = 8;
|
||||||
|
pub const SPACE_MD: u8 = 12;
|
||||||
|
pub const SPACE_LG: u8 = 16;
|
||||||
|
pub const SPACE_XL: u8 = 20;
|
||||||
|
|
||||||
|
// Border radius
|
||||||
|
pub const RADIUS_PRIMARY: u8 = 8;
|
||||||
|
pub const RADIUS_SECONDARY: u8 = 6;
|
||||||
|
pub const RADIUS_TERTIARY: u8 = 4;
|
||||||
|
pub const RADIUS_PILL: u16 = 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the `@define-color` block that all bread apps use.
|
||||||
|
/// Apps append their own rules below this; user CSS goes on top.
|
||||||
|
pub fn css_vars(p: &Palette) -> String {
|
||||||
|
format!(
|
||||||
|
"@define-color bg {bg};\n\
|
||||||
|
@define-color fg {fg};\n\
|
||||||
|
@define-color surface {c0};\n\
|
||||||
|
@define-color red {c1};\n\
|
||||||
|
@define-color green {c2};\n\
|
||||||
|
@define-color yellow {c3};\n\
|
||||||
|
@define-color blue {c4};\n\
|
||||||
|
@define-color pink {c5};\n\
|
||||||
|
@define-color teal {c6};\n\
|
||||||
|
@define-color overlay {c7};\n\
|
||||||
|
* {{ font-family: '{font}'; font-size: {size}px; }}\n",
|
||||||
|
bg = p.background,
|
||||||
|
fg = p.foreground,
|
||||||
|
c0 = p.color0,
|
||||||
|
c1 = p.color1,
|
||||||
|
c2 = p.color2,
|
||||||
|
c3 = p.color3,
|
||||||
|
c4 = p.color4,
|
||||||
|
c5 = p.color5,
|
||||||
|
c6 = p.color6,
|
||||||
|
c7 = p.color7,
|
||||||
|
font = tokens::FONT_FAMILY,
|
||||||
|
size = tokens::FONT_SIZE_BASE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||||
|
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
|
||||||
|
let h = hex.trim_start_matches('#');
|
||||||
|
let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
|
||||||
|
let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
|
||||||
|
let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
|
||||||
|
format!("rgba({r}, {g}, {b}, {alpha})")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn css_vars_contains_all_define_color_names() {
|
||||||
|
let css = css_vars(&Palette::default());
|
||||||
|
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||||
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn css_vars_contains_font_rule() {
|
||||||
|
let css = css_vars(&Palette::default());
|
||||||
|
assert!(css.contains("Varela Round"));
|
||||||
|
assert!(css.contains("14px"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_to_rgba_known_value() {
|
||||||
|
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_to_rgba_strips_hash() {
|
||||||
|
let a = hex_to_rgba("#ffffff", 0.5);
|
||||||
|
let b = hex_to_rgba("ffffff", 0.5);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
bread-theme/src/palette.rs
Normal file
162
bread-theme/src/palette.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Full 8-colour pywal palette. Catppuccin Mocha is the fallback.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Palette {
|
||||||
|
pub background: String,
|
||||||
|
pub foreground: String,
|
||||||
|
/// ANSI color0 — darkest surface / overlay
|
||||||
|
pub color0: String,
|
||||||
|
/// ANSI color1 — red
|
||||||
|
pub color1: String,
|
||||||
|
/// ANSI color2 — green
|
||||||
|
pub color2: String,
|
||||||
|
/// ANSI color3 — yellow
|
||||||
|
pub color3: String,
|
||||||
|
/// ANSI color4 — blue (primary accent)
|
||||||
|
pub color4: String,
|
||||||
|
/// ANSI color5 — pink / magenta
|
||||||
|
pub color5: String,
|
||||||
|
/// ANSI color6 — teal / cyan
|
||||||
|
pub color6: String,
|
||||||
|
/// ANSI color7 — light overlay / muted fg
|
||||||
|
pub color7: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Palette {
|
||||||
|
fn default() -> Self {
|
||||||
|
Palette {
|
||||||
|
background: "#1e1e2e".into(),
|
||||||
|
foreground: "#cdd6f4".into(),
|
||||||
|
color0: "#45475a".into(),
|
||||||
|
color1: "#f38ba8".into(),
|
||||||
|
color2: "#a6e3a1".into(),
|
||||||
|
color3: "#f9e2af".into(),
|
||||||
|
color4: "#89b4fa".into(),
|
||||||
|
color5: "#f5c2e7".into(),
|
||||||
|
color6: "#94e2d5".into(),
|
||||||
|
color7: "#bac2de".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WalColors {
|
||||||
|
#[serde(default)]
|
||||||
|
colors: HashMap<String, String>,
|
||||||
|
special: Option<WalSpecial>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WalSpecial {
|
||||||
|
background: Option<String>,
|
||||||
|
foreground: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load palette from pywal's `colors.json`. Falls back to Catppuccin Mocha.
|
||||||
|
pub fn load_palette() -> Palette {
|
||||||
|
let path = wal_path();
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| from_wal_json(&s))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||||
|
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||||
|
let c = |k: &str, fallback: &str| -> String {
|
||||||
|
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
|
||||||
|
};
|
||||||
|
Some(Palette {
|
||||||
|
background: wal.special.as_ref().and_then(|s| s.background.clone())
|
||||||
|
.unwrap_or_else(|| "#1e1e2e".into()),
|
||||||
|
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone())
|
||||||
|
.unwrap_or_else(|| "#cdd6f4".into()),
|
||||||
|
color0: c("color0", "#45475a"),
|
||||||
|
color1: c("color1", "#f38ba8"),
|
||||||
|
color2: c("color2", "#a6e3a1"),
|
||||||
|
color3: c("color3", "#f9e2af"),
|
||||||
|
color4: c("color4", "#89b4fa"),
|
||||||
|
color5: c("color5", "#f5c2e7"),
|
||||||
|
color6: c("color6", "#94e2d5"),
|
||||||
|
color7: c("color7", "#bac2de"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wal_path() -> PathBuf {
|
||||||
|
dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||||
|
.join("wal/colors.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TOKYO_NIGHT: &str = r##"{
|
||||||
|
"special": { "background": "#1a1b26", "foreground": "#c0caf5" },
|
||||||
|
"colors": {
|
||||||
|
"color0": "#15161e", "color1": "#f7768e", "color2": "#9ece6a",
|
||||||
|
"color3": "#e0af68", "color4": "#7aa2f7", "color5": "#bb9af7",
|
||||||
|
"color6": "#7dcfff", "color7": "#a9b1d6"
|
||||||
|
}
|
||||||
|
}"##;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_catppuccin_mocha() {
|
||||||
|
let p = Palette::default();
|
||||||
|
assert_eq!(p.background, "#1e1e2e");
|
||||||
|
assert_eq!(p.foreground, "#cdd6f4");
|
||||||
|
assert_eq!(p.color4, "#89b4fa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_json_parses_special() {
|
||||||
|
let p = from_wal_json(TOKYO_NIGHT).unwrap();
|
||||||
|
assert_eq!(p.background, "#1a1b26");
|
||||||
|
assert_eq!(p.foreground, "#c0caf5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_json_parses_colors() {
|
||||||
|
let p = from_wal_json(TOKYO_NIGHT).unwrap();
|
||||||
|
assert_eq!(p.color0, "#15161e");
|
||||||
|
assert_eq!(p.color4, "#7aa2f7");
|
||||||
|
assert_eq!(p.color7, "#a9b1d6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_json_missing_special_uses_catppuccin_fallback() {
|
||||||
|
let p = from_wal_json(r#"{"colors":{}}"#).unwrap();
|
||||||
|
assert_eq!(p.background, "#1e1e2e");
|
||||||
|
assert_eq!(p.foreground, "#cdd6f4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_json_missing_color_uses_catppuccin_fallback() {
|
||||||
|
let p = from_wal_json(r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##).unwrap();
|
||||||
|
assert_eq!(p.color4, "#89b4fa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_json_returns_none() {
|
||||||
|
assert!(from_wal_json("not json").is_none());
|
||||||
|
assert!(from_wal_json("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_object_returns_all_defaults() {
|
||||||
|
let p = from_wal_json("{}").unwrap();
|
||||||
|
assert_eq!(p.background, "#1e1e2e");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_palette_returns_valid_hex_strings() {
|
||||||
|
let p = load_palette();
|
||||||
|
for val in [&p.background, &p.foreground, &p.color0, &p.color4] {
|
||||||
|
assert!(val.starts_with('#'), "expected hex, got: {val}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
registry/bread-ecosystem.toml
Normal file
33
registry/bread-ecosystem.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Human-authored product registry.
|
||||||
|
# gen-index.sh reads this + each product's bakery.toml to produce index.json.
|
||||||
|
|
||||||
|
[ecosystem]
|
||||||
|
name = "bread"
|
||||||
|
description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland"
|
||||||
|
homepage = "https://breadway.dev"
|
||||||
|
dl_base = "https://dl.breadway.dev"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "bread"
|
||||||
|
repo = "Breadway/bread"
|
||||||
|
description = "Reactive automation daemon and CLI for Linux desktops"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "breadbar"
|
||||||
|
repo = "Breadway/breadbar"
|
||||||
|
description = "Minimal status bar and notification daemon for Hyprland"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "breadbox"
|
||||||
|
repo = "Breadway/breadbox"
|
||||||
|
description = "App launcher for Hyprland / Wayland"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "breadcrumbs"
|
||||||
|
repo = "Breadway/breadcrumbs"
|
||||||
|
description = "Profile-aware Wi-Fi state machine with Tailscale integration"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "breadpad"
|
||||||
|
repo = "Breadway/breadpad"
|
||||||
|
description = "Quick-capture scratchpad and note viewer with AI classification"
|
||||||
151
scripts/gen-index.sh
Normal file
151
scripts/gen-index.sh
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate dl.breadway.dev/index.json from:
|
||||||
|
# - registry/bread-ecosystem.toml (product list)
|
||||||
|
# - <repo>/bakery.toml (per-product metadata)
|
||||||
|
# - /srv/breadway-dl/ (built binaries + sha256 files)
|
||||||
|
#
|
||||||
|
# Run on hestia after each product build, before the dl server is refreshed.
|
||||||
|
# Requires: jq, python3 (for toml parsing via tomllib), sha256sum
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DL_DIR="${DL_DIR:-/srv/breadway-dl}"
|
||||||
|
DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
|
||||||
|
GH_BASE="https://github.com/Breadway"
|
||||||
|
OUT="${DL_DIR}/index.json"
|
||||||
|
|
||||||
|
# Products are read from the registry. Each line is "name repo".
|
||||||
|
products=(
|
||||||
|
"bread Breadway/bread"
|
||||||
|
"breadbar Breadway/breadbar"
|
||||||
|
"breadbox Breadway/breadbox"
|
||||||
|
"breadcrumbs Breadway/breadcrumbs"
|
||||||
|
"breadpad Breadway/breadpad"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build a JSON package entry for one product.
|
||||||
|
# $1 = product name, $2 = github repo slug
|
||||||
|
build_package_json() {
|
||||||
|
local name="$1"
|
||||||
|
local repo="$2"
|
||||||
|
|
||||||
|
# Find the latest version dir under DL_DIR/<name>/
|
||||||
|
local pkg_dir="${DL_DIR}/${name}"
|
||||||
|
if [[ ! -d "${pkg_dir}" ]]; then
|
||||||
|
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The latest symlink must point to the current version dir.
|
||||||
|
local latest_link="${pkg_dir}/latest"
|
||||||
|
if [[ ! -L "${latest_link}" ]]; then
|
||||||
|
echo " warning: no 'latest' symlink for ${name}" >&2
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local version_dir
|
||||||
|
version_dir="$(readlink -f "${latest_link}")"
|
||||||
|
local version
|
||||||
|
version="$(basename "${version_dir}")"
|
||||||
|
|
||||||
|
# Collect all binaries in the version dir (files without .sha256 extension).
|
||||||
|
local binaries_json="[]"
|
||||||
|
for bin_path in "${version_dir}"/*; do
|
||||||
|
[[ "${bin_path}" == *.sha256 ]] && continue
|
||||||
|
[[ -f "${bin_path}" ]] || continue
|
||||||
|
local bin_name
|
||||||
|
bin_name="$(basename "${bin_path}")"
|
||||||
|
local sha256_path="${bin_path}.sha256"
|
||||||
|
local sha256=""
|
||||||
|
if [[ -f "${sha256_path}" ]]; then
|
||||||
|
sha256="$(awk '{print $1}' "${sha256_path}")"
|
||||||
|
fi
|
||||||
|
local dl_url="${DL_BASE}/${name}/${version}/${bin_name}"
|
||||||
|
local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
|
||||||
|
|
||||||
|
local entry
|
||||||
|
entry="$(jq -n \
|
||||||
|
--arg name "${bin_name}" \
|
||||||
|
--arg dl_url "${dl_url}" \
|
||||||
|
--arg github_url "${gh_url}" \
|
||||||
|
--arg sha256 "${sha256}" \
|
||||||
|
'{name: $name, dl_url: $dl_url, github_url: $github_url, sha256: $sha256}')"
|
||||||
|
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read bakery.toml for this product from a co-located checkout if available,
|
||||||
|
# else use minimal defaults.
|
||||||
|
local bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
|
||||||
|
local description=""
|
||||||
|
local system_deps="[]"
|
||||||
|
local bread_deps="[]"
|
||||||
|
local services="[]"
|
||||||
|
local config="null"
|
||||||
|
local post_install="[]"
|
||||||
|
|
||||||
|
if [[ -f "${bakery_toml}" ]]; then
|
||||||
|
description="$(python3 -c "
|
||||||
|
import tomllib, sys
|
||||||
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
|
d = tomllib.load(f)
|
||||||
|
print(d.get('description', ''))
|
||||||
|
" 2>/dev/null || true)"
|
||||||
|
system_deps="$(python3 -c "
|
||||||
|
import tomllib, json, sys
|
||||||
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
|
d = tomllib.load(f)
|
||||||
|
print(json.dumps(d.get('system_deps', [])))
|
||||||
|
" 2>/dev/null || echo "[]")"
|
||||||
|
bread_deps="$(python3 -c "
|
||||||
|
import tomllib, json, sys
|
||||||
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
|
d = tomllib.load(f)
|
||||||
|
print(json.dumps(d.get('bread_deps', [])))
|
||||||
|
" 2>/dev/null || echo "[]")"
|
||||||
|
post_install="$(python3 -c "
|
||||||
|
import tomllib, json, sys
|
||||||
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
|
d = tomllib.load(f)
|
||||||
|
print(json.dumps(d.get('install', {}).get('post_install', [])))
|
||||||
|
" 2>/dev/null || echo "[]")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg name "${name}" \
|
||||||
|
--arg description "${description}" \
|
||||||
|
--arg version "${version}" \
|
||||||
|
--argjson binaries "${binaries_json}" \
|
||||||
|
--argjson system_deps "${system_deps}" \
|
||||||
|
--argjson bread_deps "${bread_deps}" \
|
||||||
|
--argjson services "${services}" \
|
||||||
|
--argjson post_install "${post_install}" \
|
||||||
|
'{
|
||||||
|
name: $name,
|
||||||
|
description: $description,
|
||||||
|
version: $version,
|
||||||
|
binaries: $binaries,
|
||||||
|
system_deps: $system_deps,
|
||||||
|
bread_deps: $bread_deps,
|
||||||
|
services: $services,
|
||||||
|
post_install: $post_install
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assemble the full index.
|
||||||
|
packages_json="{}"
|
||||||
|
for entry in "${products[@]}"; do
|
||||||
|
name="$(echo "${entry}" | awk '{print $1}')"
|
||||||
|
repo="$(echo "${entry}" | awk '{print $2}')"
|
||||||
|
echo "processing ${name}…"
|
||||||
|
pkg="$(build_package_json "${name}" "${repo}" 2>&1)" || { echo " skipping ${name}: ${pkg}"; continue; }
|
||||||
|
[[ -z "${pkg}" ]] && continue
|
||||||
|
packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')"
|
||||||
|
done
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg version "1" \
|
||||||
|
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
|
--argjson packages "${packages_json}" \
|
||||||
|
'{version: $version, generated_at: $generated_at, packages: $packages}' \
|
||||||
|
> "${OUT}"
|
||||||
|
|
||||||
|
echo "wrote ${OUT}"
|
||||||
59
scripts/get.sh
Normal file
59
scripts/get.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Bootstrap script: installs the `bakery` binary.
|
||||||
|
# Usage: curl https://breadway.dev/get | sh
|
||||||
|
# Or: curl -sSfL https://breadway.dev/get | sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
|
||||||
|
DL_PRIMARY="https://dl.breadway.dev/bakery/${BAKERY_VERSION}/bakery-x86_64"
|
||||||
|
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/${BAKERY_VERSION}/bakery-x86_64"
|
||||||
|
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Verify platform.
|
||||||
|
uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
|
||||||
|
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
|
||||||
|
|
||||||
|
# Pick a download tool.
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
fetch() { curl -fsSL "$1" -o "$2"; }
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
fetch() { wget -q "$1" -O "$2"; }
|
||||||
|
else
|
||||||
|
die "curl or wget required"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${BIN_DIR}"
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "${TMP}"' EXIT
|
||||||
|
|
||||||
|
echo "downloading bakery…"
|
||||||
|
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
|
||||||
|
echo " from dl.breadway.dev"
|
||||||
|
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
|
||||||
|
echo " from GitHub (fallback)"
|
||||||
|
else
|
||||||
|
die "failed to download bakery from both primary and fallback URLs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "${TMP}"
|
||||||
|
cp "${TMP}" "${BIN_DIR}/bakery"
|
||||||
|
echo "installed bakery to ${BIN_DIR}/bakery"
|
||||||
|
|
||||||
|
# Warn if bin dir is not on PATH.
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*":${BIN_DIR}:"*) ;;
|
||||||
|
*)
|
||||||
|
echo ""
|
||||||
|
echo " note: ${BIN_DIR} is not in PATH — add to your shell profile:"
|
||||||
|
echo " export PATH=\"${BIN_DIR}:\$PATH\""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "get started:"
|
||||||
|
echo " bakery list # see all available packages"
|
||||||
|
echo " bakery install bread # install the automation daemon"
|
||||||
|
echo " bakery install breadbar # install the status bar"
|
||||||
|
echo " bakery install breadpad # install the scratchpad"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue