use std::{collections::HashMap, fs, path::Path};
use anyhow::{Context, Result, anyhow};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use crate::{
auth::token::get_auth_user,
context::AppContext,
utils::{
picker::{ItemKind, PickItem},
ui::spinner,
},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct StackManifest {
pub schema_version: String,
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
pub template: String,
#[serde(default)]
pub addons: Vec<StackAddon>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StackAddon {
pub id: String,
#[serde(default = "default_command")]
pub command: String,
#[serde(default)]
pub inputs: HashMap<String, String>,
}
fn default_command() -> String {
"install".to_string()
}
pub fn load_stack(path: &Path) -> Result<StackManifest> {
let file = if path.is_dir() {
path.join("anesis.stack.json")
} else {
path.to_path_buf()
};
let raw = fs::read_to_string(&file)
.with_context(|| format!("could not read stack manifest at {}", file.display()))?;
let stack: StackManifest =
serde_json::from_str(&raw).with_context(|| format!("invalid stack manifest {}", file.display()))?;
validate(&stack)?;
Ok(stack)
}
fn validate(stack: &StackManifest) -> Result<()> {
if stack.template.trim().is_empty() {
return Err(anyhow!("stack '{}' declares no template", stack.id));
}
for (i, addon) in stack.addons.iter().enumerate() {
if addon.id.trim().is_empty() {
return Err(anyhow!("stack '{}': addon #{} has an empty id", stack.id, i + 1));
}
}
Ok(())
}
#[derive(Deserialize)]
struct StackResponse {
config: StackManifest,
}
#[derive(Serialize)]
struct PublishStackDto {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
visibility: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
repo_credential_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
organization_id: Option<String>,
}
#[derive(Deserialize)]
pub struct CatalogStack {
pub stack_id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub star_count: i64,
pub config: StackManifest,
}
#[derive(Deserialize)]
struct Paginated {
data: Vec<CatalogStack>,
total_pages: i64,
}
pub async fn fetch_stack_manifest(ctx: &AppContext, stack_id: &str) -> Result<StackManifest> {
let token = get_auth_user(&ctx.paths.auth).ok().map(|u| u.token);
let mut req = ctx.client.get(format!("{}/stack/{stack_id}", ctx.backend_url));
if let Some(token) = token {
req = req.bearer_auth(token);
}
let res: StackResponse = req
.send()
.await
.with_context(|| format!("Failed to fetch stack '{stack_id}' from registry"))?
.error_for_status()
.with_context(|| format!("Stack '{stack_id}' not found in registry"))?
.json()
.await
.with_context(|| format!("Failed to parse stack '{stack_id}'"))?;
validate(&res.config)?;
Ok(res.config)
}
pub async fn fetch_stack_catalog(ctx: &AppContext) -> Result<Vec<CatalogStack>> {
let token = get_auth_user(&ctx.paths.auth).ok().map(|u| u.token);
let mut all = Vec::new();
let mut page = 1;
loop {
let mut req = ctx
.client
.get(format!("{}/stack/all", ctx.backend_url))
.query(&[("page", page), ("page_size", 100)]);
if let Some(token) = &token {
req = req.bearer_auth(token);
}
let res: Paginated = req
.send()
.await
.context("Failed to connect to server for the stack catalog")?
.error_for_status()
.context("Server returned an error for the stack catalog")?
.json()
.await
.context("Failed to parse the stack catalog")?;
let total_pages = res.total_pages;
all.extend(res.data);
if page >= total_pages {
break;
}
page += 1;
}
Ok(all)
}
pub async fn publish_stack(
ctx: &AppContext,
stack_url: &str,
update: bool,
visibility: Option<String>,
credential_id: Option<String>,
org_id: Option<String>,
) -> Result<()> {
let user = get_auth_user(&ctx.paths.auth)?;
let (method, verb) = if update {
(ctx.client.patch(format!("{}/stack/", ctx.backend_url)), "Updating")
} else {
(ctx.client.post(format!("{}/stack/publish", ctx.backend_url)), "Publishing")
};
let sp = spinner(format!("{verb} stack to registry..."));
let res = method
.bearer_auth(user.token)
.header("Content-Type", "application/json")
.json(&PublishStackDto {
url: stack_url.to_string(),
visibility,
repo_credential_id: credential_id,
organization_id: org_id,
})
.send()
.await
.inspect_err(|_| sp.finish_and_clear())?
.error_for_status()
.inspect_err(|_| sp.finish_and_clear())?;
sp.finish_and_clear();
let body: serde_json::Value = res.json().await.unwrap_or_default();
let message = body
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Done");
println!("✅ {message}");
if let Some(id) = body.get("stack_id").and_then(|m| m.as_str()) {
println!(" Stack: {id}");
}
Ok(())
}
pub async fn stack_pick_items(ctx: &AppContext) -> Result<Vec<PickItem>> {
let stacks = fetch_stack_catalog(ctx).await?;
Ok(
stacks
.into_iter()
.map(|s| {
let meta = if s.star_count > 0 {
format!(" · {}★", s.star_count)
} else {
String::new()
};
PickItem {
kind: ItemKind::Stack,
haystack: format!("{} {} {}", s.stack_id, s.name, s.description).to_lowercase(),
id: s.stack_id,
name: s.name,
meta,
description: s.description,
}
})
.collect(),
)
}
fn cached_path(ctx: &AppContext, stack_id: &str) -> std::path::PathBuf {
ctx.paths.stacks.join(format!("{stack_id}.json"))
}
pub async fn install_stack(ctx: &AppContext, stack_id: &str) -> Result<StackManifest> {
let manifest = fetch_stack_manifest(ctx, stack_id).await?;
fs::create_dir_all(&ctx.paths.stacks)?;
fs::write(cached_path(ctx, stack_id), serde_json::to_string_pretty(&manifest)?)?;
Ok(manifest)
}
pub fn remove_cached_stack(ctx: &AppContext, stack_id: &str) -> Result<()> {
let path = cached_path(ctx, stack_id);
if !path.exists() {
return Err(anyhow!("Stack '{stack_id}' is not installed locally"));
}
fs::remove_file(path)?;
println!("✓ Removed stack '{stack_id}'");
Ok(())
}
pub fn read_installed_stacks(ctx: &AppContext) -> Result<Vec<StackManifest>> {
let dir = &ctx.paths.stacks;
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& let Ok(m) = load_stack(&path)
{
out.push(m);
}
}
Ok(out)
}
pub async fn resolve_stack(ctx: &AppContext, reference: &str) -> Result<StackManifest> {
let path = Path::new(reference);
if path.exists() {
return load_stack(path);
}
let cached = cached_path(ctx, reference);
if cached.exists() {
return load_stack(&cached);
}
install_stack(ctx, reference).await
}
pub fn print_installed_stacks(ctx: &AppContext, json: bool) -> Result<()> {
let stacks = read_installed_stacks(ctx)?;
if json {
println!("{}", serde_json::to_string_pretty(&stacks)?);
return Ok(());
}
if stacks.is_empty() {
println!("No stacks installed yet. Install one with `anesis stack install <id>`.");
return Ok(());
}
for s in &stacks {
println!(
"{} {} {}",
s.id.cyan().bold(),
s.name,
format!("({} + {} addons)", s.template, s.addons.len()).dimmed()
);
}
Ok(())
}
pub async fn stack_info(ctx: &AppContext, stack_id: &str, json: bool) -> Result<()> {
let manifest = match fetch_stack_manifest(ctx, stack_id).await {
Ok(m) => m,
Err(e) => {
let cached = cached_path(ctx, stack_id);
if cached.exists() {
load_stack(&cached)?
} else {
return Err(e);
}
}
};
if json {
println!("{}", serde_json::to_string_pretty(&manifest)?);
return Ok(());
}
println!("{} {}", manifest.name.bold(), format!("({})", manifest.id).dimmed());
if !manifest.description.is_empty() {
println!("{}", manifest.description);
}
println!("\n{} {}", "template:".dimmed(), manifest.template.cyan());
println!("{}", "addons:".dimmed());
for a in &manifest.addons {
println!(" {} {}", a.id.cyan(), a.command.dimmed());
}
println!(
"\nScaffold it: {}",
format!("anesis new <dir> --stack {}", manifest.id).cyan()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_defaults_command() {
let stack: StackManifest = serde_json::from_str(
r#"{"schema_version":"1","id":"s","name":"S","template":"nest-express",
"addons":[{"id":"nest-config"},{"id":"nest-prisma","command":"generate","inputs":{"db":"postgres"}}]}"#,
)
.unwrap();
validate(&stack).unwrap();
assert_eq!(stack.addons[0].command, "install");
assert_eq!(stack.addons[1].command, "generate");
assert_eq!(stack.addons[1].inputs.get("db").unwrap(), "postgres");
}
#[test]
fn rejects_empty_template_and_addon_id() {
let mut stack: StackManifest = serde_json::from_str(
r#"{"schema_version":"1","id":"s","name":"S","template":"nest"}"#,
)
.unwrap();
stack.template = " ".into();
assert!(validate(&stack).is_err());
stack.template = "nest".into();
stack.addons.push(StackAddon { id: "".into(), command: "install".into(), inputs: HashMap::new() });
assert!(validate(&stack).is_err());
}
}