use std::collections::HashMap;
use std::io::Write;
use anyhow::{Context, bail};
use astrid_capsule::capsule::CapsuleId;
use astrid_core::dirs::AstridHome;
use indicatif::{ProgressBar, ProgressStyle};
use super::distro::lock::{DistroLock, DistroLockMeta, LockedCapsule, write_lock_to_daemon};
#[cfg(test)]
use super::distro::lock::{load_lock, manifest_hash, write_lock};
use super::distro::manifest::DistroCapsule;
use crate::theme::Theme;
#[path = "init_signed_source.rs"]
mod signed_source;
use signed_source::{PreparedDistro, prepare_distro_source, unpack_prepared};
mod lifetime;
pub(crate) use lifetime::ProvisioningLease;
mod environment;
pub(crate) use environment::write_env_files;
#[derive(Debug, Clone, Default)]
#[allow(
clippy::struct_excessive_bools,
reason = "distinct CLI flag toggles, not a state machine"
)]
pub(crate) struct InitOpts {
pub(crate) yes: bool,
pub(crate) offline: bool,
pub(crate) allow_unsigned: bool,
pub(crate) accept_new_key: bool,
pub(crate) vars: HashMap<String, String>,
pub(crate) target_principal: astrid_core::PrincipalId,
pub(crate) grant_capsules: bool,
pub(crate) require_signed: bool,
}
pub(crate) use grant::apply_self_grant;
pub(crate) fn parse_cli_vars(raw: &[String]) -> anyhow::Result<HashMap<String, String>> {
let mut map = HashMap::new();
for item in raw {
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:?})");
}
map.insert(key.to_string(), value.to_string());
}
Ok(map)
}
fn validate_install_source(
distro_source: &str,
opts: &InitOpts,
operator: &astrid_core::PrincipalId,
target: &astrid_core::PrincipalId,
) -> anyhow::Result<bool> {
let shuttle_install = distro_source.ends_with(".shuttle");
if shuttle_install && opts.grant_capsules {
bail!(
"--grant-capsules is not supported for .shuttle installs yet — \
install first, then grant with `astrid --principal {operator} \
agent modify {target} \
--add-capsule <name>` for each installed capsule."
);
}
Ok(shuttle_install)
}
pub(crate) async fn run_init(
distro_source: &str,
opts: &InitOpts,
) -> anyhow::Result<ProvisioningLease> {
let home = AstridHome::resolve()?;
let operator = crate::principal::current();
let target = opts.target_principal.clone();
grant::validate_grant_capsules(opts.grant_capsules, !distro_source.is_empty())?;
let prepared = {
let operator = operator.clone();
validate_install_source(distro_source, opts, &operator, &target)?;
prepare_distro_source(distro_source, opts, &home).await?
};
let daemon_lease = lifetime::retain_daemon().await?;
if opts.grant_capsules {
grant::preflight_grants(&operator, &target).await?;
}
let _provisioning_lock = ensure_init_workspace(&home, &target)?;
if matches!(prepared, PreparedDistro::Shuttle) {
run_init_from_shuttle(distro_source, opts).await?;
return Ok(daemon_lease);
}
let (manifest, expected_manifest_hash, signed_bundle) = unpack_prepared(prepared);
super::distro::validate::enforce_astrid_version(&manifest)?;
let display_name = manifest
.distro
.pretty_name
.as_deref()
.unwrap_or(&manifest.distro.name);
eprintln!("{}", Theme::header(&format!("Installing {display_name}")));
if let Some(ref desc) = manifest.distro.description {
eprintln!(" {desc}");
}
eprintln!();
let variables = manifest.variables;
let distro_id = manifest.distro.id;
let distro_version = manifest.distro.version;
let schema_version = manifest.schema_version;
let selected = select_capsules(manifest.capsules, opts.yes)?;
let _capsule_staging;
let selected = if let Some(bundle) = &signed_bundle {
let staging = tempfile::tempdir().context("create signed capsule staging")?;
let resolved =
signed_source::resolve_signed_capsules(&selected, bundle, staging.path()).await?;
_capsule_staging = Some(staging);
resolved
} else {
_capsule_staging = None;
selected
};
let vars = collect_variables(&variables, &selected, opts.yes, &opts.vars)?;
write_env_files(&home, &target, &selected, &variables, &vars)?;
let total = selected.len();
let install_result = install_capsules_with_resume(
&selected,
opts.offline,
&target,
signed_bundle.as_ref().map(|bundle| &bundle.pinned_refs),
)
.await?;
let locked = install_result.locked;
let newly_installed_names = install_result.newly_installed_names;
let succeeded = locked.len();
reject_total_install_failure(total, succeeded)?;
if should_write_lock(total, succeeded) {
onboard_llm_providers(&home, &target, &selected);
}
let lock = create_lock_from_parts(
schema_version,
&distro_id,
&distro_version,
&expected_manifest_hash,
locked,
);
let wrote_lock = persist_lock_if_earned_daemon(&target, total, succeeded, &lock).await?;
eprintln!();
if wrote_lock {
eprintln!("{}", Theme::success("Installation complete."));
let grant_names = grant::completed_grant_names(
opts.grant_capsules,
&lock.capsules,
&newly_installed_names,
);
grant::apply_or_hint_grants(&operator, &target, &grant_names, opts.grant_capsules).await?;
eprintln!(" Run {} to start.", Theme::prompt("astrid"));
Ok(daemon_lease)
} else {
bail!(
"Installation incomplete: {succeeded}/{total} capsule(s) installed — \
re-run `astrid init` to retry the rest."
)
}
}
async fn run_init_from_shuttle(source: &str, opts: &InitOpts) -> anyhow::Result<()> {
super::distro::shuttle_install::install_from_shuttle(std::path::Path::new(source), opts).await
}
fn ensure_init_workspace(
home: &AstridHome,
target: &astrid_core::PrincipalId,
) -> anyhow::Result<grant::ProvisioningLock> {
home.ensure()?;
let provisioning_lock = grant::ProvisioningLock::acquire(home, target)?;
init_workspace()?;
Ok(provisioning_lock)
}
fn init_workspace() -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let ws = astrid_core::dirs::WorkspaceDir::from_path_with_layout(
&cwd,
crate::workspace_layout::current().clone(),
);
if !ws.dot_astrid().exists() {
ws.ensure()?;
let config_path = ws.dot_astrid().join("config.toml");
if !config_path.exists() {
std::fs::write(
&config_path,
"# Astrid workspace configuration\n\
# See docs for available options.\n",
)?;
}
}
Ok(())
}
pub(super) fn resolve_distro_url(source: &str) -> anyhow::Result<String> {
if source.starts_with("http://") || source.starts_with("https://") {
Ok(source.to_string())
} else if let Some(repo_path) = source.strip_prefix('@') {
let mut segments = repo_path.split('/');
let valid = matches!(
(segments.next(), segments.next(), segments.next()),
(Some(owner), Some(repo), None) if !owner.is_empty() && !repo.is_empty()
);
if !valid {
bail!(
"distro source '{source}' must use @owner/repo, a URL, a local Distro.toml path, or a .shuttle archive"
);
}
Ok(format!(
"https://raw.githubusercontent.com/{repo_path}/main/Distro.toml"
))
} else {
bail!(
"distro source '{source}' must use @owner/repo, a URL, a local Distro.toml path, or a .shuttle archive"
)
}
}
fn parse_provider_selection(input: &str, count: usize) -> Vec<usize> {
let mut seen = std::collections::HashSet::new();
input
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= count)
.filter(|&n| seen.insert(n))
.collect()
}
pub(crate) fn select_capsules(
capsules: Vec<DistroCapsule>,
yes: bool,
) -> anyhow::Result<Vec<DistroCapsule>> {
if yes {
return select_capsules_headless(capsules);
}
let mut selected = Vec::new();
let mut groups: HashMap<String, Vec<DistroCapsule>> = HashMap::new();
for cap in capsules {
if let Some(ref group) = cap.group {
groups.entry(group.clone()).or_default().push(cap);
} else {
selected.push(cap);
}
}
for (group_name, group_caps) in &groups {
if group_name == "llm" {
eprintln!("Which LLM provider(s) do you want to set up?");
} else {
eprintln!("Select {group_name} provider(s):");
}
for (i, cap) in group_caps.iter().enumerate() {
eprintln!(" [{}] {}", i.saturating_add(1), cap.name);
}
eprint!("Enter numbers (comma-separated, e.g. 1,2): ");
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choices = parse_provider_selection(&input, group_caps.len());
if choices.is_empty() {
eprintln!(" No selection — defaulting to {}", group_caps[0].name);
selected.push(group_caps[0].clone());
} else {
for idx in choices {
selected.push(group_caps[idx.saturating_sub(1)].clone());
}
}
eprintln!();
}
Ok(selected)
}
fn select_capsules_headless(capsules: Vec<DistroCapsule>) -> anyhow::Result<Vec<DistroCapsule>> {
let mut selected = Vec::new();
let mut groups: HashMap<String, Vec<DistroCapsule>> = HashMap::new();
for cap in capsules {
match &cap.group {
None => selected.push(cap),
Some(group) => groups.entry(group.clone()).or_default().push(cap),
}
}
let mut group_names: Vec<String> = groups.keys().cloned().collect();
group_names.sort_unstable();
for group_name in group_names {
let group_caps = groups.remove(&group_name).unwrap_or_default();
let defaults: Vec<DistroCapsule> =
group_caps.iter().filter(|c| c.default).cloned().collect();
if defaults.is_empty() {
let first = group_caps
.first()
.ok_or_else(|| anyhow::anyhow!("group '{group_name}' has no capsules"))?;
eprintln!(
"{}",
Theme::warning(&format!(
"group '{group_name}' has no default capsule — selecting first: {}",
first.name
))
);
selected.push(first.clone());
} else {
selected.extend(defaults);
}
}
Ok(selected)
}
pub(crate) fn collect_variables(
variables: &HashMap<String, super::distro::manifest::VariableDef>,
selected: &[DistroCapsule],
yes: bool,
cli_vars: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, String>> {
let mut needed_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
for cap in selected {
for value in cap.env.values() {
for var in extract_var_refs(value) {
needed_vars.insert(var.to_string());
}
}
}
if needed_vars.is_empty() {
return Ok(HashMap::new());
}
if yes {
return collect_variables_headless(variables, &needed_vars, cli_vars, |k| {
std::env::var(k).ok()
});
}
eprintln!("Configuration:");
let mut vars = HashMap::new();
let mut sorted_vars: Vec<&str> = needed_vars.iter().map(String::as_str).collect();
sorted_vars.sort_unstable();
for var_name in sorted_vars {
let Some(def) = variables.get(var_name) else {
continue;
};
let desc = def.description.as_deref().unwrap_or(var_name);
let default_hint = def
.default
.as_ref()
.map(|d| format!(" [{d}]"))
.unwrap_or_default();
eprint!(" {desc}{default_hint}: ");
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
let value = if input.is_empty() {
def.default.clone().unwrap_or_default()
} else {
input.to_string()
};
if !value.is_empty() {
vars.insert(var_name.to_string(), value);
}
}
eprintln!();
Ok(vars)
}
fn collect_variables_headless(
variables: &HashMap<String, super::distro::manifest::VariableDef>,
needed_vars: &std::collections::HashSet<String>,
cli_vars: &HashMap<String, String>,
env_lookup: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<HashMap<String, String>> {
let mut vars = HashMap::new();
let mut sorted: Vec<&str> = needed_vars.iter().map(String::as_str).collect();
sorted.sort_unstable();
for var_name in sorted {
let env_key = format!("ASTRID_VAR_{}", var_name.to_uppercase());
let var_def = variables.get(var_name);
let value = cli_vars
.get(var_name)
.cloned()
.or_else(|| env_lookup(&env_key))
.or_else(|| var_def.and_then(|d| d.default.clone()))
.ok_or_else(|| {
anyhow::anyhow!(
"required variable '{var_name}' has no value \
(no --var {var_name}=…, no {env_key}, no default)"
)
})?;
let is_secret = var_def.is_some_and(|d| d.secret);
if is_secret {
tracing::debug!(var = %var_name, "resolved distro variable [secret]");
} else {
tracing::debug!(var = %var_name, value = %value, "resolved distro variable");
}
vars.insert(var_name.to_string(), value);
}
Ok(vars)
}
fn should_write_lock(total: usize, succeeded: usize) -> bool {
total == 0 || succeeded == total
}
fn reject_total_install_failure(total: usize, succeeded: usize) -> anyhow::Result<()> {
if total > 0 && succeeded == 0 {
bail!(
"all {total} capsule install(s) failed — not writing Distro.lock. \
Fix the errors above and re-run `astrid init`."
);
}
Ok(())
}
#[cfg(test)]
fn persist_lock_if_earned(
lock_path: &std::path::Path,
total: usize,
succeeded: usize,
lock: &DistroLock,
) -> anyhow::Result<bool> {
if should_write_lock(total, succeeded) {
write_lock(lock_path, lock)?;
return Ok(true);
}
Ok(false)
}
async fn persist_lock_if_earned_daemon(
principal: &astrid_core::PrincipalId,
total: usize,
succeeded: usize,
lock: &DistroLock,
) -> anyhow::Result<bool> {
if should_write_lock(total, succeeded) {
write_lock_to_daemon(principal, lock).await?;
return Ok(true);
}
Ok(false)
}
fn create_lock_from_parts(
schema_version: u32,
distro_id: &str,
distro_version: &str,
manifest_hash: &str,
capsules: Vec<LockedCapsule>,
) -> DistroLock {
DistroLock {
schema_version,
distro: DistroLockMeta {
id: distro_id.to_string(),
version: distro_version.to_string(),
resolved_at: chrono::Utc::now().to_rfc3339(),
},
capsules,
manifest_hash: Some(manifest_hash.to_string()),
}
}
fn extract_var_refs(template: &str) -> Vec<&str> {
template
.split("{{")
.skip(1)
.filter_map(|s| s.split_once("}}"))
.map(|(var, _)| var.trim())
.filter(|var| !var.is_empty())
.collect()
}
fn resolve_template(template: &str, vars: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
let pattern = format!("{{{{ {key} }}}}");
result = result.replace(&pattern, value);
let compact = format!("{{{{{key}}}}}");
result = result.replace(&compact, value);
}
result
}
fn is_network_capsule_source(source: &str) -> bool {
astrid_capsule_install::github_source::parse_github_source(source.trim()).is_some()
}
fn refuse_offline_network_sources(selected: &[DistroCapsule], offline: bool) -> anyhow::Result<()> {
if !offline {
return Ok(());
}
for cap in selected {
if is_network_capsule_source(&cap.source) {
bail!(
"--offline: capsule '{}' has a network/GitHub source '{}' — \
refusing to fetch. Use a .shuttle archive for a self-contained \
offline install.",
cap.name,
cap.source
);
}
}
Ok(())
}
async fn install_capsules(
selected: &[DistroCapsule],
offline: bool,
principal: &astrid_core::PrincipalId,
pinned_refs: Option<&HashMap<String, String>>,
) -> anyhow::Result<Vec<LockedCapsule>> {
Ok(
install_capsules_with_resume(selected, offline, principal, pinned_refs)
.await?
.locked,
)
}
struct InstallCapsulesResult {
locked: Vec<LockedCapsule>,
newly_installed_names: Vec<String>,
}
async fn install_capsules_with_resume(
selected: &[DistroCapsule],
offline: bool,
principal: &astrid_core::PrincipalId,
pinned_refs: Option<&HashMap<String, String>>,
) -> anyhow::Result<InstallCapsulesResult> {
super::capsule::install_daemon::reset_batch_install_budget();
refuse_offline_network_sources(selected, offline)?;
let total = selected.len();
let pb = ProgressBar::new(total as u64);
pb.set_style(
ProgressStyle::with_template(" [{bar:30}] {pos}/{len} {msg}")
.expect("valid template")
.progress_chars("=> "),
);
let mut locked = Vec::with_capacity(total);
let mut newly_installed_names = Vec::new();
let mut failed = Vec::new();
for (index, cap) in selected.iter().enumerate() {
pb.set_message(cap.name.clone());
let expected = CapsuleId::new(cap.name.clone())?;
let mut pinned_capsule = cap.clone();
if let Some(resolved_ref) = pinned_refs.and_then(|refs| refs.get(&cap.name)) {
pinned_capsule
.tag
.get_or_insert_with(|| resolved_ref.clone());
}
let refspec = super::capsule::install::RefSpec::from_capsule(&pinned_capsule);
let outcome = match super::capsule::install::install_capsule_batch(
&cap.source,
&expected,
false,
&refspec,
principal,
)
.await
{
Ok(outcome) => outcome,
Err(e) => {
if super::capsule::install_daemon::batch_install_budget_exhausted(&e) {
failed.extend(
selected[index..]
.iter()
.map(|deferred| deferred.name.clone()),
);
break;
}
eprintln!("\n Failed to install {}: {e}", cap.name);
failed.push(cap.name.clone());
pb.inc(1);
continue;
},
};
let expected_ref = pinned_refs.and_then(|refs| refs.get(&cap.name).map(String::as_str));
let verified = match validate_batch_install(&expected, &cap.version, expected_ref, outcome)
{
Ok(verified) => verified,
Err(e) => {
eprintln!("\n Failed to install {}: {e}", cap.name);
failed.push(cap.name.clone());
pb.inc(1);
continue;
},
};
locked.push(LockedCapsule {
name: cap.name.clone(),
version: verified.version,
source: cap.source.clone(),
hash: verified
.wasm_hash
.map(|h| format!("blake3:{h}"))
.unwrap_or_default(),
resolved_ref: verified.resolved_ref,
});
if !verified.skipped {
newly_installed_names.push(cap.name.clone());
}
pb.inc(1);
}
pb.finish_and_clear();
if failed.is_empty() {
eprintln!(" Installed {total} capsule(s).");
} else {
eprintln!(
" Installed {} capsule(s), {} failed: {}",
total.saturating_sub(failed.len()),
failed.len(),
failed.join(", "),
);
}
Ok(InstallCapsulesResult {
locked,
newly_installed_names,
})
}
#[derive(Debug)]
struct VerifiedBatchInstall {
version: String,
wasm_hash: Option<String>,
resolved_ref: Option<String>,
skipped: bool,
}
fn validate_batch_install(
expected: &CapsuleId,
declared_version: &str,
expected_ref: Option<&str>,
outcome: super::capsule::install::BatchInstallOutcome,
) -> anyhow::Result<VerifiedBatchInstall> {
if outcome.installed.len() != 1 {
let actual = outcome
.installed
.iter()
.map(|installed| installed.id.as_str())
.collect::<Vec<_>>()
.join(", ");
bail!(
"distro declared capsule '{expected}', but the checked installer reported [{actual}]"
);
}
let installed = outcome
.installed
.into_iter()
.next()
.expect("length checked");
if installed.id != *expected {
bail!(
"distro declared capsule '{expected}', but the checked installer reported '{}'",
installed.id
);
}
if !declared_version.is_empty() && installed.version != declared_version {
bail!(
"capsule '{expected}' release selector declared version {declared_version}, but the installed manifest reports {}",
installed.version
);
}
if let Some(expected_ref) = expected_ref
&& outcome.resolved_ref.as_deref() != Some(expected_ref)
{
bail!(
"capsule '{expected}' signed ref {expected_ref} did not match installed ref {:?}",
outcome.resolved_ref
);
}
Ok(VerifiedBatchInstall {
version: installed.version,
wasm_hash: installed.wasm_hash,
resolved_ref: outcome.resolved_ref,
skipped: installed.skipped,
})
}
fn onboard_llm_providers(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
selected: &[DistroCapsule],
) {
for cap in selected {
if cap.group.as_deref() != Some("llm") {
continue;
}
let target_dir =
match astrid_capsule_install::resolve_target_dir_for(home, principal, &cap.name, false)
{
Ok(dir) => dir,
Err(e) => {
eprintln!(" Skipping {} onboarding: {e}", cap.name);
continue;
},
};
let manifest_path = target_dir.join("Capsule.toml");
let manifest = match astrid_capsule::discovery::load_manifest(&manifest_path) {
Ok(m) => m,
Err(e) => {
eprintln!(" Skipping {} onboarding (no manifest): {e}", cap.name);
continue;
},
};
if manifest.env.is_empty() {
continue;
}
eprintln!();
eprintln!("{}", Theme::header(&format!("Configure {}", cap.name)));
if let Err(e) = super::capsule::install_prompts::prompt_env_fields(
&manifest.env,
&cap.name,
&home.config_path(),
principal,
) {
eprintln!(" Configuration for {} failed: {e}", cap.name);
}
}
}
#[path = "init_grant.rs"]
mod grant;
#[cfg(test)]
#[path = "init_tests.rs"]
mod tests;