use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, bail};
use astrid_capsule_install::github_source::{
capsule_assets, extract_github_org_repo, parse_github_source, pick_capsule,
};
use astrid_capsule_install::{InstallOptions, InstallOutput};
use astrid_core::dirs::AstridHome;
use astrid_events::EventBus;
use super::install_prompts::{cli_elicit_handler, prompt_env_fields};
pub(crate) use astrid_capsule_install::resolve_target_dir_for;
pub(crate) use super::install_update::update_capsule;
static BATCH_MODE: AtomicBool = AtomicBool::new(false);
pub(super) fn split_version_suffix(source: &str) -> (&str, Option<&str>) {
let Some(rest) = source.strip_prefix('@') else {
return (source, None);
};
match rest.split_once('@') {
Some((base, version)) if !version.is_empty() => {
let base_len = base.len().saturating_add(1); (&source[..base_len], Some(version))
},
_ => (source, None),
}
}
pub(crate) async fn install_capsule(
source: &str,
capsule: Option<&str>,
workspace: bool,
) -> anyhow::Result<()> {
let (installed, _resolved) =
install_capsule_inner(source, capsule, workspace, &RefSpec::default()).await?;
super::live_load::nudge_daemon_reload(&installed).await;
Ok(())
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RefSpec {
pub(crate) version: Option<String>,
pub(crate) tag: Option<String>,
}
impl RefSpec {
pub(crate) fn from_capsule(cap: &super::super::distro::manifest::DistroCapsule) -> Self {
Self {
version: (!cap.version.is_empty()).then(|| cap.version.clone()),
tag: cap.tag.clone(),
}
}
}
pub(crate) async fn install_capsule_batch(
source: &str,
name_hint: Option<&str>,
workspace: bool,
refspec: &RefSpec,
) -> anyhow::Result<Option<String>> {
BATCH_MODE.store(true, Ordering::Relaxed);
let result = install_capsule_inner(source, name_hint, workspace, refspec).await;
BATCH_MODE.store(false, Ordering::Relaxed);
result.map(|(_ids, resolved_ref)| resolved_ref)
}
async fn install_capsule_inner(
source: &str,
name_hint: Option<&str>,
workspace: bool,
refspec: &RefSpec,
) -> anyhow::Result<(Vec<String>, Option<String>)> {
let home = AstridHome::resolve()?;
let (base, suffix_version) = split_version_suffix(source);
let version = refspec
.version
.clone()
.or_else(|| suffix_version.map(str::to_string));
let tag = refspec.tag.clone();
if base.starts_with('.') || base.starts_with('/') {
let ids = install_from_local(base, workspace, &home, Some(base))?;
return Ok((ids, None));
}
if let Some(repo) = base.strip_prefix('@') {
let url = format!("https://github.com/{repo}");
return install_from_github(
&url,
workspace,
&home,
Some(base),
name_hint,
version.as_deref(),
tag.as_deref(),
)
.await;
}
if base.starts_with("github.com/") || base.starts_with("https://github.com/") {
return install_from_github(
base,
workspace,
&home,
Some(base),
name_hint,
version.as_deref(),
tag.as_deref(),
)
.await;
}
let ids = install_from_local(base, workspace, &home, Some(base))?;
Ok((ids, None))
}
fn github_token() -> Option<String> {
["GH_TOKEN", "GITHUB_TOKEN"].into_iter().find_map(|key| {
std::env::var(key)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
fn github_api_client() -> anyhow::Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(token) = github_token() {
match reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) {
Ok(mut value) => {
value.set_sensitive(true);
headers.insert(reqwest::header::AUTHORIZATION, value);
},
Err(_) => {
eprintln!(
"warning: ignoring malformed GH_TOKEN/GITHUB_TOKEN \
(not a valid HTTP header value); proceeding with \
anonymous GitHub API access"
);
},
}
}
reqwest::Client::builder()
.user_agent("astrid-cli")
.timeout(std::time::Duration::from_secs(30))
.default_headers(headers)
.build()
.context("failed to build GitHub HTTP client")
}
fn release_tag_url(org: &str, repo: &str, tag: &str) -> anyhow::Result<String> {
let mut url = reqwest::Url::parse(&format!(
"https://api.github.com/repos/{org}/{repo}/releases"
))
.context("failed to build GitHub releases URL")?;
url.path_segments_mut()
.map_err(|()| anyhow::anyhow!("GitHub releases URL cannot be a base"))?
.push("tags")
.push(tag);
Ok(url.to_string())
}
async fn resolve_github_ref(
client: &reqwest::Client,
org: &str,
repo: &str,
version: Option<&str>,
tag: Option<&str>,
) -> anyhow::Result<String> {
if let Some(t) = tag {
return Ok(t.to_string());
}
if let Some(v) = version {
for candidate in [format!("v{v}"), v.to_string()] {
let tag_url = release_tag_url(org, repo, &candidate)?;
let r = client.get(&tag_url).send().await.with_context(|| {
format!("failed to query release tag {candidate} for {org}/{repo}")
})?;
if r.status() == reqwest::StatusCode::NOT_FOUND {
continue;
}
if !r.status().is_success() {
bail!(
"GitHub API error querying release tag {candidate} for {org}/{repo}: HTTP {}",
r.status()
);
}
let json = r
.json::<serde_json::Value>()
.await
.with_context(|| format!("invalid GitHub API response for tag {candidate}"))?;
return Ok(json
.get("tag_name")
.and_then(serde_json::Value::as_str)
.unwrap_or(&candidate)
.to_string());
}
bail!("no GitHub release found for version {v} in {org}/{repo}");
}
tracing::debug!(%org, %repo, "no version/tag pin — resolving latest release");
let api_url = format!("https://api.github.com/repos/{org}/{repo}/releases/latest");
let r = client
.get(&api_url)
.send()
.await
.context("failed to reach GitHub API for latest release")?;
if !r.status().is_success() {
bail!(
"GitHub API returned {} for {org}/{repo} latest release",
r.status()
);
}
let json: serde_json::Value = r.json().await.context("invalid GitHub API response")?;
Ok(json
.get("tag_name")
.and_then(serde_json::Value::as_str)
.unwrap_or("latest")
.to_string())
}
async fn download_capsule_asset(
client: &reqwest::Client,
download_url: &str,
dest: &Path,
) -> anyhow::Result<()> {
let mut dl = client
.get(download_url)
.send()
.await
.context("failed to start capsule download")?;
let mut bytes = Vec::new();
while let Some(chunk) = dl.chunk().await? {
bytes.extend_from_slice(&chunk);
anyhow::ensure!(
bytes.len() <= 50 * 1024 * 1024,
"capsule archive exceeds 50 MB limit",
);
}
std::fs::write(dest, &bytes).with_context(|| format!("failed to write {}", dest.display()))?;
Ok(())
}
async fn install_from_github(
url: &str,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
name_hint: Option<&str>,
version: Option<&str>,
tag: Option<&str>,
) -> anyhow::Result<(Vec<String>, Option<String>)> {
let client = github_api_client()?;
let (org, repo) = extract_github_org_repo(url).ok_or_else(|| {
anyhow::anyhow!("Invalid GitHub URL format. Expected github.com/org/repo or @org/repo")
})?;
let pinned = version.is_some() || tag.is_some();
match resolve_github_ref(&client, org, repo, version, tag).await {
Ok(resolved_ref) => {
let api_url = release_tag_url(org, repo, &resolved_ref)?;
let candidates = if let Ok(response) = client.get(&api_url).send().await
&& response.status().is_success()
&& let Ok(json) = response.json::<serde_json::Value>().await
&& let Some(assets) = json.get("assets").and_then(serde_json::Value::as_array)
{
capsule_assets(assets)
} else {
Vec::new()
};
if !candidates.is_empty() {
let ids = match name_hint {
Some(hint) => {
let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect();
let idx = pick_capsule(&names, Some(hint))?
.expect("non-empty candidates always select an index");
let (name, download_url) = &candidates[idx];
let id = download_and_unpack(
&client,
name,
download_url,
workspace,
home,
original_source,
)
.await?;
vec![id]
},
None => {
install_all_capsules(&client, &candidates, workspace, home, original_source)
.await?
},
};
return Ok((ids, Some(resolved_ref)));
}
if pinned {
bail!("release {resolved_ref} of {org}/{repo} ships no .capsule asset");
}
},
Err(e) if pinned => {
return Err(e).context(format!(
"failed to resolve pinned version/tag for {org}/{repo}"
));
},
Err(_) => {},
}
let id = clone_and_build(url, repo, workspace, home, original_source, name_hint)?;
Ok((vec![id], None))
}
pub(crate) async fn resolve_capsule_to_file(
source: &str,
version: Option<&str>,
tag: Option<&str>,
name_hint: Option<&str>,
dest_path: &Path,
) -> anyhow::Result<String> {
let (org, repo) = parse_github_source(source).ok_or_else(|| {
anyhow::anyhow!(
"seal can only resolve GitHub-backed capsule sources (@org/repo); got {source:?}"
)
})?;
let client = github_api_client()?;
let resolved_ref = resolve_github_ref(&client, &org, &repo, version, tag).await?;
let api_url = release_tag_url(&org, &repo, &resolved_ref)?;
let response = client
.get(&api_url)
.send()
.await
.context("failed to fetch release metadata")?;
if !response.status().is_success() {
bail!(
"GitHub API returned {} fetching release {resolved_ref} of {org}/{repo}",
response.status()
);
}
let json: serde_json::Value = response.json().await.context("invalid release metadata")?;
let assets = json
.get("assets")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let candidates = capsule_assets(assets);
let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect();
let Some(idx) = pick_capsule(&names, name_hint)? else {
bail!(
"release {resolved_ref} of {org}/{repo} ships no .capsule asset — \
seal requires pre-built release artifacts"
);
};
let (_, download_url) = &candidates[idx];
download_capsule_asset(&client, download_url, dest_path).await?;
Ok(resolved_ref)
}
async fn download_and_unpack(
client: &reqwest::Client,
name: &str,
download_url: &str,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<String> {
let tmp_dir = tempfile::tempdir()?;
let sanitized_name = Path::new(name).file_name().unwrap_or_default();
let download_path = tmp_dir.path().join(sanitized_name);
let mut dl = client.get(download_url).send().await?;
let mut bytes = Vec::new();
while let Some(chunk) = dl.chunk().await? {
bytes.extend_from_slice(&chunk);
anyhow::ensure!(
bytes.len() <= 50 * 1024 * 1024,
"capsule archive exceeds 50 MB limit",
);
}
std::fs::write(&download_path, &bytes)?;
unpack_via_lib(&download_path, workspace, home, original_source)
}
async fn install_all_capsules(
client: &reqwest::Client,
candidates: &[(String, String)],
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<Vec<String>> {
eprintln!("Release ships {} capsule(s):", candidates.len());
let mut installed: Vec<String> = Vec::new();
let mut failed: Vec<(&str, String)> = Vec::new();
for (name, download_url) in candidates {
eprintln!("Installing {name}...");
match download_and_unpack(client, name, download_url, workspace, home, original_source)
.await
{
Ok(id) => installed.push(id),
Err(e) => {
eprintln!(" Failed to install {name}: {e}");
failed.push((name, e.to_string()));
},
}
}
eprintln!(
"Done: {} installed, {} failed.",
installed.len(),
failed.len()
);
if !failed.is_empty() {
let names = failed
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", ");
bail!("failed to install {} capsule(s): {names}", failed.len());
}
Ok(installed)
}
fn clone_and_build(
url: &str,
repo: &str,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
name_hint: Option<&str>,
) -> anyhow::Result<String> {
let tmp_dir = tempfile::tempdir().context("failed to create temp dir for cloning")?;
let clone_dir = tmp_dir.path().join(repo);
let status = std::process::Command::new("git")
.args(["clone", "--depth", "1", url, &clone_dir.to_string_lossy()])
.status()
.context("Failed to spawn git clone")?;
if !status.success() {
bail!("Failed to clone repository from GitHub.");
}
let output_dir = tmp_dir.path().join("dist");
std::fs::create_dir_all(&output_dir)?;
let build_bin = crate::bootstrap::find_companion_binary("astrid-build")?;
let build_status = std::process::Command::new(build_bin)
.arg(clone_dir.to_str().context("Invalid clone dir path")?)
.arg("--output")
.arg(output_dir.to_str().context("Invalid output dir path")?)
.status()
.context("Failed to run astrid-build")?;
if !build_status.success() {
bail!(
"astrid-build failed with exit code {}",
build_status.code().unwrap_or(1)
);
}
let mut produced: Vec<std::path::PathBuf> = Vec::new();
for entry in std::fs::read_dir(&output_dir)? {
let entry = match entry {
Ok(e) => e,
Err(err) => {
eprintln!("warning: skipping unreadable build-output entry: {err}");
continue;
},
};
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("capsule") {
produced.push(path);
}
}
let names: Vec<&str> = produced
.iter()
.map(|p| p.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
if let Some(idx) = pick_capsule(&names, name_hint)? {
return unpack_via_lib(&produced[idx], workspace, home, original_source);
}
bail!("astrid-build produced no .capsule archive.");
}
fn install_from_local(
source: &str,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let source_path = Path::new(source);
if !source_path.exists() {
bail!("Source path does not exist: {source}");
}
if source_path.is_file() && source.ends_with(".capsule") {
return unpack_via_lib(source_path, workspace, home, original_source).map(|id| vec![id]);
}
if source_path.is_dir() && source_path.join("Cargo.toml").exists() {
let tmp_dir = tempfile::tempdir().context("failed to create temp dir for building")?;
let output_dir = tmp_dir.path().join("dist");
let build_bin = crate::bootstrap::find_companion_binary("astrid-build")?;
let status = std::process::Command::new(build_bin)
.arg(source)
.arg("--output")
.arg(output_dir.to_str().context("Invalid output dir path")?)
.arg("--type")
.arg("rust")
.status()
.context("Failed to run astrid-build")?;
if !status.success() {
bail!(
"astrid-build failed with exit code {}",
status.code().unwrap_or(1)
);
}
for entry in std::fs::read_dir(&output_dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("capsule") {
return unpack_via_lib(&entry.path(), workspace, home, original_source)
.map(|id| vec![id]);
}
}
bail!("Failed to auto-build capsule from Cargo project.");
}
install_from_local_path(source_path, workspace, home, original_source).map(|id| vec![id])
}
pub(crate) fn install_from_local_path(
source_dir: &Path,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<String> {
let opts = InstallOptions {
workspace,
original_source: original_source.map(String::from),
skip_import_check: BATCH_MODE.load(Ordering::Relaxed),
lifecycle_bus: None,
};
let principal = crate::principal::current();
let output = run_with_elicit(opts, |opts, bus| {
astrid_capsule_install::install_from_local_path_for_principal(
source_dir,
home,
InstallOptions {
lifecycle_bus: Some(bus),
..opts
},
&principal,
)
})?;
finish_install(&output, home)
}
pub(crate) fn install_offline_capsule(
archive: &Path,
home: &AstridHome,
name: &str,
original_source: &str,
resolved_ref: Option<&str>,
signer: Option<&str>,
signature: Option<&str>,
) -> anyhow::Result<()> {
BATCH_MODE.store(true, Ordering::Relaxed);
let result = (|| {
unpack_via_lib(archive, false, home, Some(original_source))?;
let target_dir = resolve_target_dir_for(home, &crate::principal::current(), name, false)?;
if let Some(mut meta) = super::meta::read_meta(&target_dir) {
meta.resolved_ref = resolved_ref.map(String::from);
meta.signer = signer.map(String::from);
meta.signature = signature.map(String::from);
super::meta::write_meta(&target_dir, &meta)?;
}
Ok(())
})();
BATCH_MODE.store(false, Ordering::Relaxed);
result
}
fn unpack_via_lib(
archive: &Path,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<String> {
let opts = InstallOptions {
workspace,
original_source: original_source.map(String::from),
skip_import_check: BATCH_MODE.load(Ordering::Relaxed),
lifecycle_bus: None,
};
let principal = crate::principal::current();
let output = run_with_elicit(opts, |opts, bus| {
astrid_capsule_install::unpack_and_install_for_principal(
archive,
home,
InstallOptions {
lifecycle_bus: Some(bus),
..opts
},
&principal,
)
})?;
finish_install(&output, home)
}
fn run_with_elicit<F>(opts: InstallOptions, f: F) -> anyhow::Result<InstallOutput>
where
F: FnOnce(InstallOptions, EventBus) -> anyhow::Result<InstallOutput>,
{
let event_bus = EventBus::with_capacity(128);
let receiver = event_bus.subscribe_topic("astrid.v1.elicit");
let bus_for_handler = event_bus.clone();
let elicit_task = tokio::runtime::Handle::try_current().ok().map(|h| {
h.spawn(async move {
cli_elicit_handler(receiver, bus_for_handler).await;
})
});
let result = f(opts, event_bus.clone());
if let Some(task) = elicit_task {
task.abort();
}
drop(event_bus);
result
}
fn finish_install(output: &InstallOutput, home: &AstridHome) -> anyhow::Result<String> {
let batch = BATCH_MODE.load(Ordering::Relaxed);
let manifest_path = output.target_dir.join("Capsule.toml");
let manifest = astrid_capsule::discovery::load_manifest(&manifest_path)
.context("re-reading manifest for post-install diagnostics")?;
let capsule_id = output.target_dir.file_name().map_or_else(
|| "capsule".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let cli_commands: Vec<&astrid_capsule::manifest::CommandDef> = manifest
.commands
.iter()
.filter(|c| c.kind == astrid_core::kernel_api::CommandKind::Cli)
.collect();
if !cli_commands.is_empty() {
eprintln!();
eprintln!("This capsule adds CLI commands:");
for c in cli_commands {
let desc = c.description.as_deref().unwrap_or("(no description)");
eprintln!(" {} — {desc} (provider: {capsule_id})", c.name);
}
}
if !batch && output.env_needs_prompt {
prompt_env_fields(
&manifest.env,
&output.env_path,
&capsule_id,
&home.config_path(),
)?;
}
if !batch && !output.missing_imports.is_empty() {
let importer = output.target_dir.file_name().map_or_else(
|| "capsule".to_string(),
|n| n.to_string_lossy().into_owned(),
);
eprintln!();
for missing in &output.missing_imports {
eprintln!(
" Note: {importer} needs {}/{} {}.",
missing.namespace, missing.interface, missing.requirement
);
}
eprintln!(
" Install the missing capsule(s) or run `astrid init` to set up a complete environment."
);
}
for c in &output.export_conflicts {
tracing::info!(
interface = %c.interface,
existing = %c.existing_capsule,
"Shared export — both capsules will be active"
);
}
if !batch {
let skew = super::show::contracts_skew_at(&output.target_dir, home);
super::show::print_install_skew_notice(&capsule_id, &skew);
}
Ok(capsule_id)
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;