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, is_lock_fresh, load_lock, write_lock,
};
use super::distro::manifest::{DistroCapsule, DistroManifest, parse_manifest};
use crate::theme::Theme;
#[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) 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)
}
pub(crate) async fn run_init(distro_source: &str, opts: &InitOpts) -> anyhow::Result<()> {
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())?;
if distro_source.ends_with(".shuttle") {
if 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."
);
}
home.ensure()?;
let _provisioning_lock = grant::ProvisioningLock::acquire(&home, &target)?;
init_workspace()?;
return run_init_from_shuttle(distro_source, opts);
}
if opts.grant_capsules {
grant::preflight_grants(&operator, &target).await?;
}
home.ensure()?;
let _provisioning_lock = grant::ProvisioningLock::acquire(&home, &target)?;
init_workspace()?;
let lock_path = home
.principal_home(&target)
.config_dir()
.join("distro.lock");
let manifest = fetch_and_parse_manifest(distro_source, opts.offline).await?;
super::distro::validate::enforce_astrid_version(&manifest)?;
if let Some(existing_lock) = load_lock(&lock_path)?
&& is_lock_fresh(&existing_lock, &manifest)
&& let Some(installed) =
grant::validated_grant_set_for_reuse(&home, &target, &existing_lock.capsules)
{
eprintln!(
"{}",
Theme::info(&format!(
"{} is already installed (Distro.lock is up to date)",
manifest
.distro
.pretty_name
.as_deref()
.unwrap_or(&manifest.distro.name),
))
);
grant::apply_or_hint_grants(&operator, &target, &installed, opts.grant_capsules).await?;
return Ok(());
}
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 vars = collect_variables(&variables, &selected, opts.yes, &opts.vars)?;
write_env_files(&home, &target, &selected, &vars)?;
let total = selected.len();
let locked = install_capsules(&selected, opts.offline, &target).await?;
let succeeded = locked.len();
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`."
);
}
if should_write_lock(total, succeeded) {
onboard_llm_providers(&home, &target, &selected);
}
let installed_names: Vec<String> = locked.iter().map(|c| c.name.clone()).collect();
let lock = create_lock_from_parts(schema_version, &distro_id, &distro_version, locked);
let wrote_lock = persist_lock_if_earned(&lock_path, total, succeeded, &lock)?;
eprintln!();
if wrote_lock {
eprintln!("{}", Theme::success("Installation complete."));
grant::apply_or_hint_grants(&operator, &target, &installed_names, opts.grant_capsules)
.await?;
eprintln!(" Run {} to start.", Theme::prompt("astrid"));
Ok(())
} else {
bail!(
"Installation incomplete: {succeeded}/{total} capsule(s) installed — \
re-run `astrid init` to retry the rest."
)
}
}
fn run_init_from_shuttle(source: &str, opts: &InitOpts) -> anyhow::Result<()> {
super::distro::shuttle_install::install_from_shuttle(std::path::Path::new(source), opts)
}
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(())
}
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"
)
}
}
async fn fetch_and_parse_manifest(source: &str, offline: bool) -> anyhow::Result<DistroManifest> {
let path = std::path::Path::new(source);
if path.exists() && path.is_file() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
return parse_manifest(&content);
}
if offline {
bail!(
"--offline: '{source}' is not a local file and network fetch is forbidden \
(use a Distro.toml path or a .shuttle archive)"
);
}
let url = resolve_distro_url(source)?;
eprintln!("Fetching distro manifest...");
let client = reqwest::Client::builder()
.user_agent("astrid-cli")
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client
.get(&url)
.send()
.await
.context("failed to fetch distro manifest")?;
if !response.status().is_success() {
bail!(
"failed to fetch distro manifest from {url} (HTTP {})",
response.status(),
);
}
let mut bytes = Vec::new();
let mut response = response;
while let Some(chunk) = response.chunk().await? {
bytes.extend_from_slice(&chunk);
anyhow::ensure!(
bytes.len() <= 1024 * 1024,
"distro manifest exceeds 1 MB limit",
);
}
let content = std::str::from_utf8(&bytes).context("distro manifest contains invalid UTF-8")?;
parse_manifest(content)
}
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 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)
}
fn create_lock_from_parts(
schema_version: u32,
distro_id: &str,
distro_version: &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: None,
}
}
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()
}
async fn install_capsules(
selected: &[DistroCapsule],
offline: bool,
principal: &astrid_core::PrincipalId,
) -> anyhow::Result<Vec<LockedCapsule>> {
if offline {
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
);
}
}
}
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 failed = Vec::new();
for cap in selected {
pb.set_message(cap.name.clone());
let expected = CapsuleId::new(cap.name.clone())?;
let refspec = super::capsule::install::RefSpec::from_capsule(cap);
let outcome = match super::capsule::install::install_capsule_batch(
&cap.source,
&expected,
false,
&refspec,
principal,
)
.await
{
Ok(outcome) => outcome,
Err(e) => {
eprintln!("\n Failed to install {}: {e}", cap.name);
failed.push(cap.name.clone());
pb.inc(1);
continue;
},
};
let verified = match validate_batch_install(&expected, &cap.version, 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,
});
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(locked)
}
#[derive(Debug)]
struct VerifiedBatchInstall {
version: String,
wasm_hash: Option<String>,
resolved_ref: Option<String>,
}
fn validate_batch_install(
expected: &CapsuleId,
declared_version: &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
);
}
Ok(VerifiedBatchInstall {
version: installed.version,
wasm_hash: installed.wasm_hash,
resolved_ref: outcome.resolved_ref,
})
}
pub(crate) fn write_env_files(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
selected: &[DistroCapsule],
vars: &HashMap<String, String>,
) -> anyhow::Result<()> {
let env_dir = home.principal_home(principal).env_dir();
std::fs::create_dir_all(&env_dir)?;
for cap in selected {
if cap.env.is_empty() {
continue;
}
let env_path = env_dir.join(format!("{}.env.json", cap.name));
if env_path.exists() {
continue;
}
let mut resolved: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for (key, template) in &cap.env {
let value = resolve_template(template, vars);
resolved.insert(key.clone(), serde_json::Value::String(value));
}
if !resolved.is_empty() {
let json = serde_json::to_string_pretty(&resolved)?;
std::fs::write(&env_path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600))?;
}
}
}
Ok(())
}
fn onboard_llm_providers(
home: &AstridHome,
principal: &astrid_core::PrincipalId,
selected: &[DistroCapsule],
) {
let env_dir = home.principal_home(principal).env_dir();
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)));
let env_path = env_dir.join(format!("{}.env.json", cap.name));
if let Err(e) = super::capsule::install_prompts::prompt_env_fields(
&manifest.env,
&env_path,
&cap.name,
&home.config_path(),
home,
principal,
) {
eprintln!(" Configuration for {} failed: {e}", cap.name);
}
}
}
#[path = "init_grant.rs"]
mod grant;
#[cfg(test)]
#[path = "init_tests.rs"]
mod tests;