use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, bail};
use astrid_capsule::capsule::CapsuleId;
use astrid_capsule_install::github_source::{
capsule_assets, extract_github_org_repo, parse_github_source, pick_capsule,
};
use astrid_capsule_install::{InstallOptions, resolve_target_dir_for_with_layout};
use astrid_core::dirs::AstridHome;
use super::install_finish::{finish_install, run_with_elicit};
pub(crate) use super::install_batch::{
BatchInstallOutcome, InstalledCapsuleOutcome, RefSpec, install_capsule_batch,
};
use super::install_github::{github_api_client, release_tag_url, resolve_github_ref};
#[derive(Clone, Copy)]
struct ExpectedCapsule<'a> {
id: &'a CapsuleId,
version: Option<&'a str>,
}
#[derive(Clone, Copy)]
struct InstallContext<'a> {
workspace: bool,
home: &'a AstridHome,
original_source: Option<&'a str>,
principal: &'a astrid_core::PrincipalId,
expected: Option<ExpectedCapsule<'a>>,
prompt: &'a ManualInstallOptions,
}
#[derive(Debug, Clone, Default)]
pub(super) struct ManualInstallOptions {
pub(super) yes: bool,
pub(super) vars: HashMap<String, String>,
}
impl ManualInstallOptions {
fn from_cli(yes: bool, items: &[String]) -> anyhow::Result<Self> {
let mut vars = HashMap::new();
for item in items {
let (key, value) = item
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--var must be KEY=VALUE (got {item:?})"))?;
if key.is_empty() {
bail!("--var has an empty key (got {item:?})");
}
if vars.insert(key.to_string(), value.to_string()).is_some() {
bail!("--var '{key}' was supplied more than once");
}
}
Ok(Self { yes, vars })
}
}
#[derive(Clone, Copy)]
pub(crate) struct OfflineCapsuleProvenance<'a> {
pub(crate) original_source: &'a str,
pub(crate) resolved_ref: Option<&'a str>,
pub(crate) signer: Option<&'a str>,
pub(crate) signature: Option<&'a str>,
}
pub(crate) use astrid_capsule_install::resolve_target_dir_for;
pub(crate) use super::install_update::update_capsule;
pub(super) 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<()> {
install_capsule_with_options(source, capsule, workspace, false, &[]).await
}
pub(crate) async fn install_capsule_with_options(
source: &str,
capsule: Option<&str>,
workspace: bool,
yes: bool,
vars: &[String],
) -> anyhow::Result<()> {
let principal = crate::principal::current();
let prompt = ManualInstallOptions::from_cli(yes, vars)?;
let (installed, _resolved) = install_capsule_inner(
source,
capsule,
workspace,
&RefSpec::default(),
&principal,
None,
&prompt,
)
.await?;
let installed_ids: Vec<String> = installed
.iter()
.map(|capsule| capsule.id.as_str().to_string())
.collect();
super::live_load::nudge_daemon_reload(&installed_ids).await;
Ok(())
}
pub(super) async fn install_capsule_inner(
source: &str,
name_hint: Option<&str>,
workspace: bool,
refspec: &RefSpec,
principal: &astrid_core::PrincipalId,
expected: Option<&CapsuleId>,
prompt: &ManualInstallOptions,
) -> anyhow::Result<(Vec<InstalledCapsuleOutcome>, 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();
let expected = expected.map(|id| ExpectedCapsule {
id,
version: version.as_deref(),
});
if base.starts_with('.') || base.starts_with('/') {
let ids = install_from_local(
base,
workspace,
&home,
Some(base),
principal,
expected,
prompt,
)?;
return Ok((ids, None));
}
if let Some(repo) = base.strip_prefix('@') {
let url = format!("https://github.com/{repo}");
return install_from_github(
&url,
name_hint,
version.as_deref(),
tag.as_deref(),
InstallContext {
workspace,
home: &home,
original_source: Some(base),
principal,
expected,
prompt,
},
)
.await;
}
if base.starts_with("github.com/") || base.starts_with("https://github.com/") {
return install_from_github(
base,
name_hint,
version.as_deref(),
tag.as_deref(),
InstallContext {
workspace,
home: &home,
original_source: Some(base),
principal,
expected,
prompt,
},
)
.await;
}
let ids = install_from_local(
base,
workspace,
&home,
Some(base),
principal,
expected,
prompt,
)?;
Ok((ids, None))
}
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,
name_hint: Option<&str>,
version: Option<&str>,
tag: Option<&str>,
context: InstallContext<'_>,
) -> anyhow::Result<(Vec<InstalledCapsuleOutcome>, 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, context).await?;
vec![id]
},
None => install_all_capsules(&client, &candidates, context).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, name_hint, context)?;
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,
context: InstallContext<'_>,
) -> anyhow::Result<InstalledCapsuleOutcome> {
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,
context.workspace,
context.home,
context.original_source,
context.principal,
context.expected,
context.prompt,
)
}
async fn install_all_capsules(
client: &reqwest::Client,
candidates: &[(String, String)],
context: InstallContext<'_>,
) -> anyhow::Result<Vec<InstalledCapsuleOutcome>> {
eprintln!("Release ships {} capsule(s):", candidates.len());
let mut installed: Vec<InstalledCapsuleOutcome> = 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, context).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,
name_hint: Option<&str>,
context: InstallContext<'_>,
) -> anyhow::Result<InstalledCapsuleOutcome> {
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],
context.workspace,
context.home,
context.original_source,
context.principal,
context.expected,
context.prompt,
);
}
bail!("astrid-build produced no .capsule archive.");
}
fn install_from_local(
source: &str,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
principal: &astrid_core::PrincipalId,
expected: Option<ExpectedCapsule<'_>>,
prompt: &ManualInstallOptions,
) -> anyhow::Result<Vec<InstalledCapsuleOutcome>> {
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,
principal,
expected,
prompt,
)
.map(|installed| vec![installed]);
}
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,
principal,
expected,
prompt,
)
.map(|installed| vec![installed]);
}
}
bail!("Failed to auto-build capsule from Cargo project.");
}
install_from_local_path_for_principal(
source_path,
workspace,
home,
original_source,
principal,
expected,
prompt,
)
.map(|installed| vec![installed])
}
pub(crate) fn install_from_local_path(
source_dir: &Path,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
) -> anyhow::Result<String> {
let principal = crate::principal::current();
let prompt = ManualInstallOptions::default();
install_from_local_path_for_principal(
source_dir,
workspace,
home,
original_source,
&principal,
None,
&prompt,
)
.map(|installed| installed.id.as_str().to_string())
}
fn install_from_local_path_for_principal(
source_dir: &Path,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
principal: &astrid_core::PrincipalId,
expected: Option<ExpectedCapsule<'_>>,
prompt: &ManualInstallOptions,
) -> anyhow::Result<InstalledCapsuleOutcome> {
let opts = InstallOptions {
workspace,
original_source: original_source.map(String::from),
skip_import_check: BATCH_MODE.load(Ordering::Relaxed),
lifecycle_bus: None,
};
let output = run_with_elicit(opts, prompt, |opts, bus| {
let opts = InstallOptions {
lifecycle_bus: Some(bus),
..opts
};
match expected {
Some(expected) => {
astrid_capsule_install::install_from_local_path_checked_for_principal_with_layout(
source_dir,
home,
opts,
principal,
expected.id,
expected.version,
crate::workspace_layout::current(),
)
},
None => astrid_capsule_install::install_from_local_path_for_principal_with_layout(
source_dir,
home,
opts,
principal,
crate::workspace_layout::current(),
),
}
})?;
finish_install(&output, home, principal, prompt)
}
pub(crate) fn install_offline_capsule(
archive: &Path,
home: &AstridHome,
expected: &CapsuleId,
expected_version: Option<&str>,
provenance: OfflineCapsuleProvenance<'_>,
principal: &astrid_core::PrincipalId,
) -> anyhow::Result<InstalledCapsuleOutcome> {
BATCH_MODE.store(true, Ordering::Relaxed);
let prompt = ManualInstallOptions::default();
let result = (|| {
let installed = unpack_via_lib(
archive,
false,
home,
Some(provenance.original_source),
principal,
Some(ExpectedCapsule {
id: expected,
version: expected_version,
}),
&prompt,
)?;
let target_dir = resolve_target_dir_for_with_layout(
home,
principal,
expected.as_str(),
false,
crate::workspace_layout::current(),
)?;
if let Some(mut meta) = super::meta::read_meta(&target_dir) {
meta.resolved_ref = provenance.resolved_ref.map(String::from);
meta.signer = provenance.signer.map(String::from);
meta.signature = provenance.signature.map(String::from);
super::meta::write_meta(&target_dir, &meta)?;
}
Ok(installed)
})();
BATCH_MODE.store(false, Ordering::Relaxed);
result
}
fn unpack_via_lib(
archive: &Path,
workspace: bool,
home: &AstridHome,
original_source: Option<&str>,
principal: &astrid_core::PrincipalId,
expected: Option<ExpectedCapsule<'_>>,
prompt: &ManualInstallOptions,
) -> anyhow::Result<InstalledCapsuleOutcome> {
let opts = InstallOptions {
workspace,
original_source: original_source.map(String::from),
skip_import_check: BATCH_MODE.load(Ordering::Relaxed),
lifecycle_bus: None,
};
let output = run_with_elicit(opts, prompt, |opts, bus| {
let opts = InstallOptions {
lifecycle_bus: Some(bus),
..opts
};
match expected {
Some(expected) => {
astrid_capsule_install::unpack_and_install_checked_for_principal_with_layout(
archive,
home,
opts,
principal,
expected.id,
expected.version,
crate::workspace_layout::current(),
)
},
None => astrid_capsule_install::unpack_and_install_for_principal_with_layout(
archive,
home,
opts,
principal,
crate::workspace_layout::current(),
),
}
})?;
finish_install(&output, home, principal, prompt)
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;