use anyhow::{Context, bail};
use astrid_capsule_install::github_source::{parse_github_source, strip_version_prefix};
use astrid_capsule_install::{
CapsuleLocation, InstalledCapsule, scan_installed_capsules_in_home_for_with_layout,
};
use astrid_core::dirs::AstridHome;
use super::install::install_capsule;
use super::meta::{CapsuleMeta, read_meta};
pub(super) enum UpdateCheck {
Available { latest: semver::Version },
UpToDate { latest: semver::Version },
Failed { reason: String },
Skipped { reason: String },
}
async fn fetch_github_latest_version(
client: &reqwest::Client,
org: &str,
repo: &str,
) -> anyhow::Result<semver::Version> {
let api_url = format!("https://api.github.com/repos/{org}/{repo}/releases/latest");
let response = client
.get(&api_url)
.send()
.await
.context("failed to reach GitHub API")?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!("no GitHub releases found for {org}/{repo}");
}
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
|| response.status() == reqwest::StatusCode::FORBIDDEN
{
bail!("GitHub API rate limit exceeded - try again later");
}
if !response.status().is_success() {
bail!("GitHub API returned {}", response.status());
}
let json: serde_json::Value = response
.json()
.await
.context("failed to parse GitHub API response")?;
let tag_name = json
.get("tag_name")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("GitHub release has missing or empty tag_name"))?;
let version_str = strip_version_prefix(tag_name);
semver::Version::parse(version_str)
.with_context(|| format!("GitHub tag '{tag_name}' is not valid semver"))
}
pub(super) async fn check_remote_version(
client: &reqwest::Client,
source: &str,
current_version: &str,
) -> UpdateCheck {
let Ok(current) = semver::Version::parse(current_version) else {
return UpdateCheck::Failed {
reason: format!("installed version '{current_version}' is not valid semver"),
};
};
if source.starts_with('.') || source.starts_with('/') {
return UpdateCheck::Skipped {
reason: "local source".to_string(),
};
}
if let Some((org, repo)) = parse_github_source(source) {
match fetch_github_latest_version(client, &org, &repo).await {
Ok(latest) => {
if latest > current {
UpdateCheck::Available { latest }
} else {
UpdateCheck::UpToDate { latest }
}
},
Err(e) => UpdateCheck::Failed {
reason: format!("{e}"),
},
}
} else {
UpdateCheck::Skipped {
reason: format!("unsupported source: {source}"),
}
}
}
pub(crate) async fn update_capsule(target: Option<&str>, workspace: bool) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
let principal = crate::principal::current();
if let Some(name) = target {
let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout(
&home,
&principal,
name,
workspace,
crate::workspace_layout::current(),
)?;
if !target_dir.exists() {
bail!("Capsule '{name}' is not installed.");
}
let meta = read_meta(&target_dir).ok_or_else(|| {
anyhow::anyhow!(
"Capsule '{name}' has no meta.json - cannot determine original source. \
Re-install it manually."
)
})?;
let source = meta.source.ok_or_else(|| {
anyhow::anyhow!(
"Capsule '{name}' was installed before source tracking was added. \
Re-install it manually to record the source."
)
})?;
eprintln!("Updating {name} from {source}...");
install_capsule(&source, Some(name), workspace).await
} else {
update_all_capsules(&home, &principal, workspace).await
}
}
async fn update_all_capsules(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
workspace: bool,
) -> anyhow::Result<()> {
let capsules: Vec<(String, Option<CapsuleMeta>)> = filter_update_scope(
scan_installed_capsules_in_home_for_with_layout(
home,
principal,
crate::workspace_layout::current(),
)?,
workspace,
)
.into_iter()
.map(|capsule| (capsule.name, capsule.meta))
.collect();
if capsules.is_empty() {
eprintln!("No capsules installed.");
return Ok(());
}
let client = reqwest::Client::builder()
.user_agent("astrid-cli")
.timeout(std::time::Duration::from_secs(15))
.build()?;
eprintln!(
"Checking {} installed capsule(s) for updates...",
capsules.len()
);
let mut to_update: Vec<(String, String)> = Vec::new();
let mut up_to_date = 0u32;
let mut check_failed = 0u32;
let mut skipped = 0u32;
for (name, meta) in &capsules {
let Some(meta) = meta else {
eprintln!(" {name}: skipped (no meta.json)");
skipped = skipped.saturating_add(1);
continue;
};
let Some(ref source) = meta.source else {
eprintln!(" {name}: skipped (no source recorded)");
skipped = skipped.saturating_add(1);
continue;
};
match check_remote_version(&client, source, &meta.version).await {
UpdateCheck::Available { latest } => {
eprintln!(" {name}: {} -> {latest} (update available)", meta.version);
to_update.push((name.clone(), source.clone()));
},
UpdateCheck::UpToDate { latest } => {
eprintln!(" {name}: {} (up to date, latest: {latest})", meta.version);
up_to_date = up_to_date.saturating_add(1);
},
UpdateCheck::Failed { reason } => {
eprintln!(" {name}: {} (check failed: {reason})", meta.version);
check_failed = check_failed.saturating_add(1);
},
UpdateCheck::Skipped { reason } => {
eprintln!(" {name}: skipped ({reason})");
skipped = skipped.saturating_add(1);
},
}
}
let mut updated = 0u32;
let mut install_failed = 0u32;
for (name, source) in &to_update {
eprintln!("Updating {name} from {source}...");
if let Err(e) = install_capsule(source, Some(name), workspace).await {
eprintln!(" Failed to update {name}: {e}");
install_failed = install_failed.saturating_add(1);
} else {
updated = updated.saturating_add(1);
}
}
eprintln!(
"Done: {updated} updated, {up_to_date} up-to-date, {check_failed} check-failed, \
{skipped} skipped, {install_failed} install-failed."
);
if updated > 0 {
regenerate_distro_lock(home, principal)?;
}
Ok(())
}
fn filter_update_scope(capsules: Vec<InstalledCapsule>, workspace: bool) -> Vec<InstalledCapsule> {
let selected = if workspace {
CapsuleLocation::Workspace
} else {
CapsuleLocation::User
};
capsules
.into_iter()
.filter(|capsule| capsule.location == selected)
.collect()
}
fn regenerate_distro_lock(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
) -> anyhow::Result<()> {
use crate::commands::distro::lock::{DistroLock, DistroLockMeta, LockedCapsule, write_lock};
let lock_path = home
.principal_home(principal)
.config_dir()
.join("distro.lock");
let Some(existing) = crate::commands::distro::lock::load_lock(&lock_path)? else {
return Ok(());
};
let all = scan_installed_capsules_in_home_for_with_layout(
home,
principal,
crate::workspace_layout::current(),
)?;
let capsules: Vec<LockedCapsule> = all
.iter()
.map(|c| {
let (version, source, hash) = c.meta.as_ref().map_or_else(
|| {
eprintln!(
" Warning: {} has no meta.json, locked with empty version",
c.name,
);
(String::new(), String::new(), String::new())
},
|meta| {
(
meta.version.clone(),
meta.source.clone().unwrap_or_default(),
meta.wasm_hash
.as_ref()
.map(|h| format!("blake3:{h}"))
.unwrap_or_default(),
)
},
);
LockedCapsule {
name: c.name.clone(),
version,
source,
hash,
resolved_ref: c.meta.as_ref().and_then(|m| m.resolved_ref.clone()),
}
})
.collect();
let (id, version) = (existing.distro.id, existing.distro.version);
let lock = DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id,
version,
resolved_at: chrono::Utc::now().to_rfc3339(),
},
capsules,
manifest_hash: existing.manifest_hash,
};
write_lock(&lock_path, &lock)?;
eprintln!("Distro.lock updated.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn installed(name: &str, location: CapsuleLocation) -> InstalledCapsule {
InstalledCapsule {
name: name.to_string(),
meta: None,
location,
}
}
#[test]
fn update_scope_does_not_move_capsules_between_locations() {
let user = filter_update_scope(
vec![
installed("user-only", CapsuleLocation::User),
installed("workspace-only", CapsuleLocation::Workspace),
],
false,
);
assert_eq!(user.len(), 1);
assert_eq!(user[0].name, "user-only");
let workspace = filter_update_scope(
vec![
installed("user-only", CapsuleLocation::User),
installed("workspace-only", CapsuleLocation::Workspace),
],
true,
);
assert_eq!(workspace.len(), 1);
assert_eq!(workspace[0].name, "workspace-only");
}
#[tokio::test]
async fn test_check_remote_version_invalid_semver() {
let client = reqwest::Client::new();
let result = check_remote_version(&client, "@org/repo", "not-a-version").await;
assert!(
matches!(result, UpdateCheck::Failed { reason } if reason.contains("not valid semver"))
);
}
#[tokio::test]
async fn test_check_remote_version_local_skipped() {
let client = reqwest::Client::new();
let result = check_remote_version(&client, "./local/path", "1.0.0").await;
assert!(
matches!(result, UpdateCheck::Skipped { reason } if reason.contains("local source"))
);
let result = check_remote_version(&client, "/absolute/path", "1.0.0").await;
assert!(
matches!(result, UpdateCheck::Skipped { reason } if reason.contains("local source"))
);
}
}