use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use crate::cli::UpdateArgs;
use crate::theme::Theme;
const DEFAULT_ORG: &str = "unicity-astrid";
const DEFAULT_REPO: &str = "astrid";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CHECK_TTL_SECS: u64 = 86_400;
const MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024;
const MANAGED_BINARIES: &[&str] = &["astrid", "astrid-daemon"];
fn api_base() -> String {
std::env::var("ASTRID_UPDATE_API").unwrap_or_else(|_| "https://api.github.com".to_string())
}
fn resolve_repo(source: Option<&str>) -> anyhow::Result<(String, String)> {
let spec = source
.map(str::to_owned)
.or_else(|| std::env::var("ASTRID_UPDATE_REPO").ok());
match spec {
Some(s) => {
let (owner, repo) = s
.split_once('/')
.filter(|(o, r)| !o.is_empty() && !r.is_empty())
.ok_or_else(|| anyhow::anyhow!("update source must be 'owner/repo', got '{s}'"))?;
Ok((owner.to_string(), repo.to_string()))
},
None => Ok((DEFAULT_ORG.to_string(), DEFAULT_REPO.to_string())),
}
}
fn astrid_bin_dir() -> anyhow::Result<PathBuf> {
let home = astrid_core::dirs::AstridHome::resolve()?;
Ok(home.root().join("bin"))
}
fn platform_target() -> anyhow::Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
("macos", "x86_64") => Ok("x86_64-apple-darwin"),
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
(os, arch) => bail!("Unsupported platform: {os}/{arch}"),
}
}
fn running_binary() -> anyhow::Result<PathBuf> {
let exe = std::env::current_exe().context("cannot determine current executable path")?;
Ok(exe.canonicalize().unwrap_or(exe))
}
fn is_homebrew_managed(exe: &Path) -> bool {
exe.components().any(|c| {
c.as_os_str()
.to_str()
.is_some_and(|s| s.eq_ignore_ascii_case("Cellar"))
})
}
fn cargo_bin_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut push = |base: PathBuf| {
let bin = base.join("bin");
dirs.push(bin.canonicalize().unwrap_or(bin));
};
if let Some(home) = std::env::var_os("CARGO_HOME") {
push(PathBuf::from(home));
}
if let Some(base) = directories::BaseDirs::new() {
push(base.home_dir().join(".cargo"));
}
dirs
}
fn is_cargo_managed(exe: &Path) -> bool {
use std::ffi::OsStr;
if cargo_bin_dirs().iter().any(|dir| exe.starts_with(dir)) {
return true;
}
let comps: Vec<&OsStr> = exe
.components()
.map(std::path::Component::as_os_str)
.collect();
comps
.windows(2)
.any(|w| w[0] == OsStr::new(".cargo") && w[1] == OsStr::new("bin"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallMethod {
Homebrew,
Cargo,
SelfManaged,
}
impl InstallMethod {
fn detect(exe: &Path) -> Self {
if is_homebrew_managed(exe) {
Self::Homebrew
} else if is_cargo_managed(exe) {
Self::Cargo
} else {
Self::SelfManaged
}
}
fn upgrade_command(self) -> &'static str {
match self {
Self::Homebrew => "brew upgrade astrid",
Self::Cargo => "cargo install astrid --force",
Self::SelfManaged => "astrid update",
}
}
fn label(self) -> &'static str {
match self {
Self::Homebrew => "Homebrew",
Self::Cargo => "cargo",
Self::SelfManaged => "a self-managed install",
}
}
fn manages_own_binary(self) -> bool {
matches!(self, Self::Homebrew | Self::Cargo)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum UpdatePlan {
UpToDate,
Available { how: &'static str },
DeferToManager {
manager: &'static str,
how: &'static str,
},
ApplyInPlace,
}
fn plan_update(
method: InstallMethod,
current: &semver::Version,
latest: &semver::Version,
is_check: bool,
) -> UpdatePlan {
if latest <= current {
return UpdatePlan::UpToDate;
}
if is_check {
return UpdatePlan::Available {
how: method.upgrade_command(),
};
}
if method.manages_own_binary() {
return UpdatePlan::DeferToManager {
manager: method.label(),
how: method.upgrade_command(),
};
}
UpdatePlan::ApplyInPlace
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UpdateCache {
checked_at: u64,
latest_version: String,
}
fn cache_path() -> anyhow::Result<PathBuf> {
let home = astrid_core::dirs::AstridHome::resolve()?;
Ok(home.var_dir().join("update-check.json"))
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn write_cache(version: &str) {
let cache = UpdateCache {
checked_at: now_epoch(),
latest_version: version.to_owned(),
};
if let Ok(path) = cache_path()
&& let Ok(json) = serde_json::to_string(&cache)
{
let _ = std::fs::write(path, json);
}
}
pub(crate) async fn check_for_update_cached() -> Option<String> {
let path = cache_path().ok()?;
if let Ok(data) = std::fs::read_to_string(&path)
&& let Ok(cache) = serde_json::from_str::<UpdateCache>(&data)
&& now_epoch().saturating_sub(cache.checked_at) < CHECK_TTL_SECS
{
let current = semver::Version::parse(CURRENT_VERSION).ok()?;
let latest = semver::Version::parse(&cache.latest_version).ok()?;
return (latest > current).then_some(cache.latest_version);
}
let (owner, repo) = resolve_repo(None).ok()?;
let client = reqwest::Client::builder()
.user_agent("astrid-cli")
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
let url = format!("{}/repos/{owner}/{repo}/releases/latest", api_base());
let response = client.get(&url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let json: serde_json::Value = response.json().await.ok()?;
let tag = json.get("tag_name")?.as_str()?;
let version_str = tag.strip_prefix('v').unwrap_or(tag);
write_cache(version_str);
let current = semver::Version::parse(CURRENT_VERSION).ok()?;
let latest = semver::Version::parse(version_str).ok()?;
(latest > current).then(|| version_str.to_string())
}
pub(crate) async fn print_update_banner() {
let Some(latest) = check_for_update_cached().await else {
return;
};
let how = match running_binary() {
Ok(exe) => InstallMethod::detect(&exe).upgrade_command(),
Err(_) => "astrid update",
};
eprintln!(
"{}",
Theme::warning(&format!(
"Update available: v{CURRENT_VERSION} → v{latest}. Run `{how}` to upgrade."
))
);
}
async fn fetch_latest_release(
client: &reqwest::Client,
owner: &str,
repo: &str,
) -> anyhow::Result<(String, serde_json::Value)> {
let url = format!("{}/repos/{owner}/{repo}/releases/latest", api_base());
let response = client
.get(&url)
.send()
.await
.context("failed to reach GitHub API")?;
if !response.status().is_success() {
bail!("GitHub API returned {}", response.status());
}
let json: serde_json::Value = response
.json()
.await
.context("failed to parse API response")?;
let tag = json
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("release has no tag_name"))?;
let version = tag.strip_prefix('v').unwrap_or(tag).to_string();
Ok((version, json))
}
fn asset_url<'a>(release: &'a serde_json::Value, name: &str) -> Option<&'a str> {
release
.get("assets")?
.as_array()?
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name))
.and_then(|a| a.get("browser_download_url").and_then(|u| u.as_str()))
}
async fn download(client: &reqwest::Client, url: &str) -> anyhow::Result<Vec<u8>> {
let mut response = client.get(url).send().await?;
if !response.status().is_success() {
bail!("download failed: HTTP {}", response.status());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
bytes.extend_from_slice(&chunk);
anyhow::ensure!(
bytes.len() <= MAX_ARCHIVE_BYTES,
"release archive exceeds {MAX_ARCHIVE_BYTES} byte limit"
);
}
Ok(bytes)
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len().saturating_mul(2));
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn verify_sha256(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::Result<()> {
use sha2::{Digest, Sha256};
let expected = sums_body
.lines()
.find_map(|line| {
let mut it = line.split_whitespace();
let hex = it.next()?;
let name = it.next()?;
(name.trim_start_matches('*') == asset_name).then_some(hex)
})
.ok_or_else(|| anyhow::anyhow!("no checksum for '{asset_name}' in SHA256SUMS"))?;
let actual = to_hex(&Sha256::digest(archive));
if !actual.eq_ignore_ascii_case(expected) {
bail!("checksum mismatch for '{asset_name}': expected {expected}, got {actual}");
}
Ok(())
}
fn backup_and_swap(install_dir: &Path, extract_dir: &Path, names: &[&str]) -> anyhow::Result<()> {
for name in names {
anyhow::ensure!(
extract_dir.join(name).exists(),
"release archive is missing '{name}'"
);
}
let mut backups: Vec<(PathBuf, PathBuf)> = Vec::new(); for name in names {
let live = install_dir.join(name);
if live.exists() {
let bak = install_dir.join(format!("{name}.bak"));
std::fs::copy(&live, &bak)
.with_context(|| format!("failed to back up {}", live.display()))?;
backups.push((live, bak));
}
}
let mut staged: Vec<(PathBuf, PathBuf)> = Vec::new(); for name in names {
let tmp = install_dir.join(format!(".{name}.new"));
std::fs::copy(extract_dir.join(name), &tmp)
.with_context(|| format!("failed to stage {name}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
}
staged.push((tmp, install_dir.join(name)));
}
for (idx, (tmp, live)) in staged.iter().enumerate() {
if let Err(e) = std::fs::rename(tmp, live) {
let mut rollback_errs = Vec::new();
for (blive, bak) in &backups {
if let Err(re) = std::fs::rename(bak, blive) {
rollback_errs.push(format!("{}: {re}", blive.display()));
}
}
for (t, _) in &staged[idx..] {
let _ = std::fs::remove_file(t);
}
let base = format!("failed to install {}", live.display());
let msg = if rollback_errs.is_empty() {
base
} else {
format!(
"{base}; ROLLBACK ALSO FAILED ({}) — restore *.bak manually",
rollback_errs.join("; ")
)
};
return Err(e).context(msg);
}
}
Ok(())
}
fn is_writable_dir(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".astrid-write-probe")
.tempfile_in(dir)
.is_ok()
}
fn confirm(prompt: &str, assume_yes: bool) -> anyhow::Result<bool> {
if assume_yes || !std::io::stdin().is_terminal() {
return Ok(true);
}
eprint!("{prompt} [Y/n] ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut input = String::new();
if std::io::stdin().read_line(&mut input)? == 0 {
return Ok(false);
}
let input = input.trim();
Ok(input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes"))
}
pub(crate) async fn run_self_update(args: UpdateArgs) -> anyhow::Result<()> {
let target = platform_target()?;
let (owner, repo) = resolve_repo(args.source.as_deref())?;
let exe = running_binary()?;
let method = InstallMethod::detect(&exe);
println!(
"{}",
Theme::info(&format!(
"Checking for updates (current: v{CURRENT_VERSION}, platform: {target}, source: {owner}/{repo})..."
))
);
let client = reqwest::Client::builder()
.user_agent("astrid-cli")
.timeout(std::time::Duration::from_secs(30))
.build()?;
let (version_str, release) = fetch_latest_release(&client, &owner, &repo).await?;
let current = semver::Version::parse(CURRENT_VERSION)?;
let latest = semver::Version::parse(&version_str)?;
write_cache(&version_str);
match plan_update(method, ¤t, &latest, args.check) {
UpdatePlan::UpToDate => {
println!(
"{}",
Theme::success(&format!("Already up to date (v{CURRENT_VERSION})."))
);
return Ok(());
},
UpdatePlan::Available { how } => {
println!(
"{}",
Theme::info(&format!(
"Update available: v{CURRENT_VERSION} → v{version_str}. Run `{how}` to upgrade."
))
);
return Ok(());
},
UpdatePlan::DeferToManager { manager, how } => {
println!(
"{}",
Theme::info(&format!(
"Astrid was installed via {manager}. Update it with:\n {how}"
))
);
return Ok(());
},
UpdatePlan::ApplyInPlace => {},
}
let install_dir = exe
.parent()
.ok_or_else(|| anyhow::anyhow!("cannot resolve install directory for {}", exe.display()))?
.to_path_buf();
if !is_writable_dir(&install_dir) {
bail!(
"{} is not writable — re-run with elevated permissions, or reinstall via Homebrew/cargo.",
install_dir.display()
);
}
if !confirm(
&format!(
"Update Astrid v{CURRENT_VERSION} → v{version_str} in {}?",
install_dir.display()
),
args.yes,
)? {
println!("{}", Theme::dimmed("Update cancelled."));
return Ok(());
}
let (_tmp_dir, extract_dir) =
download_verify_extract(&client, &release, &version_str, target).await?;
backup_and_swap(&install_dir, &extract_dir, MANAGED_BINARIES)?;
println!(
"{}",
Theme::success(&format!(
"Updated to v{version_str} (previous binaries kept as *.bak in {})",
install_dir.display()
))
);
finish_update(&install_dir).await
}
async fn download_verify_extract(
client: &reqwest::Client,
release: &serde_json::Value,
version: &str,
target: &str,
) -> anyhow::Result<(tempfile::TempDir, PathBuf)> {
println!(
"{}",
Theme::info(&format!("Downloading v{version} for {target}..."))
);
let asset_name = format!("astrid-{version}-{target}.tar.gz");
let url = asset_url(release, &asset_name)
.ok_or_else(|| {
anyhow::anyhow!(
"no release asset '{asset_name}' — no pre-built binary for this platform"
)
})?
.to_string();
let archive = download(client, &url).await?;
let sums_url = asset_url(release, "SHA256SUMS.txt")
.map(str::to_owned)
.ok_or_else(|| {
anyhow::anyhow!(
"release has no SHA256SUMS.txt — refusing to install an unverifiable binary"
)
})?;
let sums = download(client, &sums_url).await?;
let sums_body = String::from_utf8(sums).context("SHA256SUMS.txt is not UTF-8")?;
verify_sha256(&archive, &sums_body, &asset_name)?;
println!("{}", Theme::dimmed("Checksum verified."));
let tmp_dir = tempfile::tempdir()?;
let archive_path = tmp_dir.path().join(&asset_name);
std::fs::write(&archive_path, &archive)?;
{
let tar_gz = std::fs::File::open(&archive_path)?;
let decoder = flate2::read::GzDecoder::new(tar_gz);
let mut tar = tar::Archive::new(decoder);
tar.unpack(tmp_dir.path())?;
}
let extract_dir = tmp_dir.path().join(format!("astrid-{version}-{target}"));
Ok((tmp_dir, extract_dir))
}
async fn finish_update(install_dir: &Path) -> anyhow::Result<()> {
if crate::socket_client::proxy_socket_path().exists() {
println!(
"{}",
Theme::info("Stopping the running daemon so the new version loads on next use...")
);
if let Err(e) = super::daemon::handle_stop().await {
println!(
"{}",
Theme::warning(&format!(
"Could not stop the daemon ({e}); restart it with `astrid restart`."
))
);
}
}
sync_distro_and_capsules().await?;
if !is_in_path(install_dir) {
println!(
"{}",
Theme::warning(&format!(
"Note: {} is not on your PATH; run `astrid init` to set it up.",
install_dir.display()
))
);
}
Ok(())
}
async fn sync_distro_and_capsules() -> anyhow::Result<()> {
println!();
println!("{}", Theme::info("Checking distro and capsule updates..."));
let home = astrid_core::dirs::AstridHome::resolve()?;
let principal = astrid_core::PrincipalId::default();
let lock_path = home
.principal_home(&principal)
.config_dir()
.join("distro.lock");
let lock = super::distro::lock::load_lock(&lock_path)?;
let distro_id = lock.as_ref().map_or("astralis", |l| l.distro.id.as_str());
let sync_opts = super::init::InitOpts {
yes: true,
..Default::default()
};
if let Err(e) = super::init::run_init(distro_id, &sync_opts).await {
println!("{}", Theme::warning(&post_update_sync_message(&e)));
}
if let Err(e) = super::capsule::install::update_capsule(None, false).await {
println!("{}", Theme::warning(&format!("Capsule update: {e}")));
}
Ok(())
}
fn post_update_sync_message(err: &anyhow::Error) -> String {
let is_version_gate = err.chain().any(|e| {
e.downcast_ref::<super::distro::validate::AstridVersionTooOld>()
.is_some()
});
if is_version_gate {
"The updated distro manifest requires the new astrid; it will take effect \
on your next run — restart astrid (or re-run `astrid distro apply`)."
.to_string()
} else {
format!("Distro sync: {err}")
}
}
fn is_in_path(dir: &Path) -> bool {
std::env::var_os("PATH").is_some_and(|p| std::env::split_paths(&p).any(|entry| entry == dir))
}
fn detect_shell_rc() -> Option<PathBuf> {
let home = directories::BaseDirs::new()?.home_dir().to_path_buf();
let shell = std::env::var("SHELL").unwrap_or_default();
if shell.ends_with("zsh") {
Some(home.join(".zshrc"))
} else if shell.ends_with("bash") {
let bashrc = home.join(".bashrc");
let profile = home.join(".bash_profile");
if cfg!(target_os = "macos") && profile.exists() {
Some(profile)
} else if bashrc.exists() {
Some(bashrc)
} else {
Some(home.join(".bashrc"))
}
} else if shell.ends_with("fish") {
Some(home.join(".config/fish/config.fish"))
} else {
let zshrc = home.join(".zshrc");
if zshrc.exists() {
Some(zshrc)
} else {
Some(home.join(".bashrc"))
}
}
}
fn match_is_commented(rc: &str, start: usize) -> bool {
let line_start = rc[..start].rfind('\n').map_or(0, |nl| nl.saturating_add(1));
rc[line_start..start].contains('#')
}
fn rc_configures_path(rc_contents: &str, bin_str: &str, export_line: &str) -> bool {
if let Some(start) = rc_contents.find(export_line)
&& !match_is_commented(rc_contents, start)
{
return true;
}
if bin_str.is_empty() {
return false;
}
let is_lead = |c: char| matches!(c, ':' | '"' | '\'' | '=' | '(' | ' ' | '\t' | '\n' | '\r');
let is_trail = |c: char| matches!(c, ':' | '"' | '\'' | ')' | ' ' | '\t' | '\n' | '\r');
let mut from = 0;
while let Some(rel) = rc_contents[from..].find(bin_str) {
let start = from.saturating_add(rel);
let end = start.saturating_add(bin_str.len());
if match_is_commented(rc_contents, start) {
from = end;
continue;
}
let lead_ok = start == 0
|| rc_contents[..start]
.chars()
.next_back()
.is_some_and(is_lead);
let trail_ok =
end == rc_contents.len() || rc_contents[end..].chars().next().is_some_and(is_trail);
if lead_ok && trail_ok {
return true;
}
from = end;
}
false
}
pub(crate) fn ensure_path_setup() -> anyhow::Result<()> {
let bin_dir = astrid_bin_dir()?;
std::fs::create_dir_all(&bin_dir)?;
if is_in_path(&bin_dir) {
return Ok(());
}
let bin_str = bin_dir.to_string_lossy();
let Some(rc_file) = detect_shell_rc() else {
println!(
"{}",
Theme::warning(&format!("Add {bin_str} to your PATH manually."))
);
return Ok(());
};
let export_line = if rc_file.to_string_lossy().contains("fish") {
format!("fish_add_path {bin_str}")
} else {
format!("export PATH=\"{bin_str}:$PATH\"")
};
if let Ok(contents) = std::fs::read_to_string(&rc_file)
&& rc_configures_path(&contents, &bin_str, &export_line)
{
return Ok(()); }
if std::io::stdin().is_terminal() {
eprint!(
"\n{bin_str} is not in your PATH. Add it to {}? [Y/n] ",
rc_file.display()
);
std::io::Write::flush(&mut std::io::stderr())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
if !input.is_empty() && !input.eq_ignore_ascii_case("y") {
println!(
"{}",
Theme::dimmed(&format!("Skipped. Add manually: {export_line}"))
);
return Ok(());
}
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&rc_file)?;
std::io::Write::write_all(
&mut file,
format!("\n# Astrid OS\n{export_line}\n").as_bytes(),
)?;
println!(
"{}",
Theme::success(&format!("Added to {}", rc_file.display()))
);
println!(
" Run: {} (or restart your terminal)",
Theme::dimmed(&format!("source {}", rc_file.display()))
);
Ok(())
}
#[cfg(test)]
#[path = "self_update_tests.rs"]
mod tests;