use std::collections::HashMap;
use std::io::Write;
use anyhow::{Context, bail};
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;
const DEFAULT_DISTRO: &str = "astralis";
const DEFAULT_ORG: &str = "unicity-astrid";
pub(crate) async fn run_init(distro_source: &str) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
home.ensure()?;
init_workspace()?;
let principal = astrid_core::PrincipalId::default();
let lock_path = home
.principal_home(&principal)
.config_dir()
.join("distro.lock");
let manifest = fetch_and_parse_manifest(distro_source).await?;
if let Some(existing_lock) = load_lock(&lock_path)?
&& is_lock_fresh(&existing_lock, &manifest)
{
eprintln!(
"{}",
Theme::info(&format!(
"{} is already installed (Distro.lock is up to date)",
manifest
.distro
.pretty_name
.as_deref()
.unwrap_or(&manifest.distro.name),
))
);
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)?;
let vars = collect_variables(&variables, &selected)?;
write_env_files(&home, &selected, &vars)?;
let locked = install_capsules(&selected).await?;
let lock = create_lock_from_parts(schema_version, &distro_id, &distro_version, locked);
write_lock(&lock_path, &lock)?;
eprintln!();
eprintln!("{}", Theme::success("Installation complete."));
eprintln!(" Run {} to start.", Theme::prompt("astrid"));
Ok(())
}
fn init_workspace() -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let ws = astrid_core::dirs::WorkspaceDir::from_path(&cwd);
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) -> String {
if source.starts_with("http://") || source.starts_with("https://") {
source.to_string()
} else if let Some(repo_path) = source.strip_prefix('@') {
format!("https://raw.githubusercontent.com/{repo_path}/main/Distro.toml")
} else {
format!("https://raw.githubusercontent.com/{DEFAULT_ORG}/{source}/main/Distro.toml")
}
}
async fn fetch_and_parse_manifest(source: &str) -> 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);
}
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 select_capsules(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 {
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 {
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: Vec<usize> = input
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= group_caps.len())
.collect();
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 collect_variables(
variables: &HashMap<String, super::distro::manifest::VariableDef>,
selected: &[DistroCapsule],
) -> 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());
}
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 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,
}
}
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
}
async fn install_capsules(selected: &[DistroCapsule]) -> anyhow::Result<Vec<LockedCapsule>> {
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();
let home = AstridHome::resolve()?;
for cap in selected {
pb.set_message(cap.name.clone());
if let Err(e) = super::capsule::install::install_capsule_batch(&cap.source, false).await {
eprintln!("\n Failed to install {}: {e}", cap.name);
failed.push(cap.name.clone());
pb.inc(1);
continue;
}
let target_dir = super::capsule::install::resolve_target_dir(&home, &cap.name, false)?;
let meta = super::capsule::meta::read_meta(&target_dir);
locked.push(LockedCapsule {
name: cap.name.clone(),
version: cap.version.clone(),
source: cap.source.clone(),
hash: meta
.and_then(|m| m.wasm_hash)
.map(|h| format!("blake3:{h}"))
.unwrap_or_default(),
});
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)
}
fn write_env_files(
home: &AstridHome,
selected: &[DistroCapsule],
vars: &HashMap<String, String>,
) -> anyhow::Result<()> {
let principal = astrid_core::PrincipalId::default();
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_var_refs_finds_all() {
assert_eq!(extract_var_refs("{{ foo }}"), vec!["foo"]);
assert_eq!(extract_var_refs("{{ a }}-{{ b }}"), vec!["a", "b"],);
assert!(extract_var_refs("no vars").is_empty());
}
#[test]
fn resolve_template_replaces_vars() {
let mut vars = HashMap::new();
vars.insert("key".to_string(), "secret123".to_string());
vars.insert("url".to_string(), "https://api.example.com".to_string());
assert_eq!(resolve_template("{{ key }}", &vars), "secret123",);
assert_eq!(
resolve_template("prefix-{{ url }}-suffix", &vars),
"prefix-https://api.example.com-suffix",
);
}
#[test]
fn resolve_template_handles_missing_var() {
let vars = HashMap::new();
assert_eq!(resolve_template("{{ missing }}", &vars), "{{ missing }}",);
}
#[test]
fn distro_source_resolution_bare_name() {
assert_eq!(
resolve_distro_url("astralis"),
"https://raw.githubusercontent.com/unicity-astrid/astralis/main/Distro.toml",
);
}
#[test]
fn distro_source_resolution_at_prefix() {
assert_eq!(
resolve_distro_url("@myorg/mydistro"),
"https://raw.githubusercontent.com/myorg/mydistro/main/Distro.toml",
);
}
#[test]
fn distro_source_resolution_full_url() {
let url = "https://example.com/Distro.toml";
assert_eq!(resolve_distro_url(url), url);
}
}