use anyhow::{bail, Context, Result};
use owo_colors::OwoColorize;
use crate::api::client::{ApiClient, ApiError};
use crate::api::models::NewService;
use crate::commands::dotenv;
use crate::render::theme;
pub async fn set_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
value: &str,
secret: bool,
sealed: bool,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client
.set_variable(&service.id, key, value, secret, sealed)
.await?;
println!(
" {} {} en {}{}",
theme::CHECK.style(theme::success()),
key.style(theme::strong()),
service.name.style(theme::strong()),
match (sealed, secret) {
(true, _) => " (sellada)",
(false, true) => " (secreto)",
_ => "",
},
);
if sealed {
println!(
" {}",
"Su valor ya no se puede leer ni cambiar en ningún sitio. Solo borrarla."
.style(theme::muted()),
);
}
println!(
" {}",
"El próximo despliegue la recogerá; el contenedor actual no la ve.".style(theme::muted()),
);
Ok(())
}
pub async fn seal_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
confirmation: Option<&str>,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let variables = client.variables(&service.id).await?;
let Some(variable) = variables.into_iter().find(|variable| variable.key == key) else {
bail!(
"`{}` no define ninguna variable llamada `{key}`",
service.name
);
};
if variable.is_sealed() {
println!(
" {} {} ya estaba sellada",
theme::CHECK.style(theme::success()),
key.style(theme::strong())
);
return Ok(());
}
if variable.kind == "reference" {
bail!("`{key}` es una referencia: no guarda ningún valor propio. Sella la variable a la que apunta.");
}
if confirmation != Some(key) {
bail!(
"escribe `--confirm {key}` para sellarla: dejará de poder verse y de poder cambiarse, y no se deshace"
);
}
client.seal_variable(&variable.id).await?;
println!(
" {} {} sellada en {}",
theme::CHECK.style(theme::success()),
key.style(theme::strong()),
service.name.style(theme::strong()),
);
println!(
" {}",
"El contenedor la sigue recibiendo. Nadie más vuelve a verla.".style(theme::muted()),
);
Ok(())
}
pub async fn unset_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let variables = client.variables(&service.id).await?;
let Some(variable) = variables.into_iter().find(|variable| variable.key == key) else {
bail!(
"`{}` no define ninguna variable llamada `{key}`",
service.name
);
};
client.remove_variable(&variable.id).await?;
println!(
" {} {} eliminada",
theme::CHECK.style(theme::success()),
key.style(theme::strong())
);
Ok(())
}
pub async fn connect(
client: &ApiClient,
project_reference: &str,
consumer_reference: &str,
provider_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let consumer = client.find_service(&project.id, consumer_reference).await?;
let provider = client.find_service(&project.id, provider_reference).await?;
let connection = client.connect(&consumer.id, &provider.id).await?;
println!(
" {} {} {} {}",
theme::CHECK.style(theme::success()),
consumer.name.style(theme::strong()),
theme::ARROW.style(theme::muted()),
connection.provider_name.style(theme::strong()),
);
println!(" {}", connection.summary.style(theme::muted()));
for key in &connection.keys {
println!(" {} {}", "+".style(theme::success()), key);
}
Ok(())
}
pub async fn candidates(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let candidates = client.candidates(&service.id).await?;
if candidates.is_empty() {
println!("No hay nada a lo que `{}` pueda conectarse.", service.name);
return Ok(());
}
for candidate in &candidates {
println!(
" {} {}",
candidate.name.style(theme::strong()),
candidate.summary.style(theme::muted()),
);
}
Ok(())
}
pub async fn add_domain(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
host: Option<&str>,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let domain = client.add_domain(&service.id, host).await?;
println!(
" {} {}",
theme::CHECK.style(theme::success()),
domain.url.style(theme::accent())
);
if let Some(instructions) = &domain.dns_instructions {
println!(" {} {}", "!".style(theme::warning()), instructions);
}
Ok(())
}
pub async fn remove_domain(client: &ApiClient, domain_id: &str) -> Result<()> {
client.remove_domain(domain_id).await?;
println!(
" {} Dominio eliminado",
theme::CHECK.style(theme::success())
);
Ok(())
}
pub async fn stop(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client.stop(&service.id).await?;
println!(
" {} {} parado — despertará con la siguiente petición",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
Ok(())
}
pub struct ServiceRequest<'a> {
pub project: &'a str,
pub name: &'a str,
pub repo: Option<&'a str>,
pub image: Option<&'a str>,
pub template: Option<&'a str>,
pub branch: &'a str,
pub connect_to: &'a [String],
}
pub async fn create_service(client: &ApiClient, request: ServiceRequest<'_>) -> Result<()> {
let project = client.find_project(request.project).await?;
let source_kind = match (request.repo, request.image, request.template) {
(Some(_), None, None) => "github",
(None, Some(_), None) => "image",
(None, None, Some(_)) => "template",
(None, None, None) => bail!("say where it comes from: --repo, --image or --template"),
_ => bail!("--repo, --image and --template are alternatives, not a set"),
};
let mut providers = Vec::new();
for reference in request.connect_to {
providers.push(client.find_service(&project.id, reference).await?.id);
}
let draft = NewService {
name: request.name.to_owned(),
source_kind: source_kind.to_owned(),
repo_full_name: request.repo.map(str::to_owned),
branch: request.repo.map(|_| request.branch.to_owned()),
image: request.image.map(str::to_owned),
template_key: request.template.map(str::to_owned),
kind: request.template.map(|_| "database".to_owned()),
connect_to: providers,
};
if let Err(error) = client.create_service(&project.id, &draft).await {
return Err(match request.template {
Some(key) => explain_template_refusal(error, key),
None => error,
});
}
println!(
" {} {} creado en {}",
theme::CHECK.style(theme::success()),
request.name.style(theme::strong()),
project.name.style(theme::strong()),
);
if !request.connect_to.is_empty() {
println!(
" {} conectado a {}",
theme::ARROW.style(theme::muted()),
request.connect_to.join(", "),
);
}
Ok(())
}
fn explain_template_refusal(error: anyhow::Error, key: &str) -> anyhow::Error {
let missing = error
.downcast_ref::<ApiError>()
.is_some_and(|api| api.code == "not_found");
if !missing {
return error;
}
error.context(format!(
"el componente `{key}` no está disponible: o no existe en esta instancia, \
o solo su dueño puede añadirlo — `fundaia templates` lista los que \
esta cuenta sí puede crear"
))
}
pub async fn templates(client: &ApiClient) -> Result<()> {
for template in client.templates().await? {
println!(
" {} {} {}",
template.key.style(theme::strong()),
template.display_name,
template.image.style(theme::muted()),
);
}
Ok(())
}
pub async fn create_project(
client: &ApiClient,
name: &str,
description: Option<&str>,
workspace_reference: Option<&str>,
) -> Result<()> {
let workspace = match workspace_reference {
Some(reference) => Some(client.find_workspace(Some(reference)).await?),
None => None,
};
let project = client
.create_project(name, description, workspace.as_ref().map(|w| w.id.as_str()))
.await?;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
project.name.style(theme::strong()),
format!("({})", project.slug).style(theme::muted()),
);
println!(
" {}",
format!("fundaia new {} <nombre> --repo <owner/repo>", project.slug).style(theme::muted()),
);
Ok(())
}
pub async fn attach_volume(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
mount_path: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let volume = client.attach_volume(&service.id, mount_path).await?;
println!(
" {} {} en {}",
theme::CHECK.style(theme::success()),
volume.mount_path.style(theme::strong()),
service.name.style(theme::strong()),
);
println!(
" {}",
"Estará dentro del contenedor a partir del próximo despliegue.".style(theme::muted()),
);
Ok(())
}
pub async fn edit_recipe(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
file: &str,
) -> Result<()> {
let containerfile = read_text(file)?;
if containerfile.trim().is_empty() {
bail!("el Containerfile está vacío");
}
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client.edit_recipe(&service.id, &containerfile).await?;
println!(
" {} Containerfile de {}",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
println!(
" {}",
"A partir de ahora se despliega este, no el generado.".style(theme::muted()),
);
Ok(())
}
pub async fn import_variables(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
file: &str,
secret: bool,
) -> Result<()> {
let variables = dotenv::variables_in(&read_text(file)?, secret);
if variables.is_empty() {
bail!("no hay ninguna variable en {file}");
}
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let imported = client.import_variables(&service.id, &variables).await?;
println!(
" {} {} en {}{}",
theme::CHECK.style(theme::success()),
format!("{imported} variables").style(theme::strong()),
service.name.style(theme::strong()),
if secret { " (secretas)" } else { "" },
);
for variable in variables.iter().take(5) {
println!(" {}", variable.key.style(theme::muted()));
}
if variables.len() > 5 {
println!(
" {}",
format!("y {} más", variables.len() - 5).style(theme::muted())
);
}
Ok(())
}
fn read_text(file: &str) -> Result<String> {
if file == "-" {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.context("no se ha podido leer la entrada estándar")?;
return Ok(text);
}
std::fs::read_to_string(file).with_context(|| format!("no se ha podido leer {file}"))
}
pub async fn take_backup(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
println!(
" {} copiando el volumen de {}…",
theme::ARROW.style(theme::muted()),
service.name.style(theme::strong()),
);
let backup = client.take_backup(&service.id).await?;
if backup.status == "failed" {
bail!(
"la copia falló: {}",
backup
.failure_message
.unwrap_or_else(|| "sin motivo".to_owned())
);
}
println!(
" {} copia {} hecha",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
);
Ok(())
}
pub async fn schedule_backups(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
schedules: &[String],
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let applied = client.schedule_backups(&service.id, schedules).await?;
if applied.is_empty() {
println!(
" {} {} ya no se copia solo",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
println!(
" {}",
"Las copias que ya existen se quedan donde están.".style(theme::muted())
);
return Ok(());
}
println!(
" {} {} se copiará: {}",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
applied.join(", ").style(theme::strong()),
);
Ok(())
}
pub async fn restore_backup(
client: &ApiClient,
backup_id: &str,
confirmation: Option<&str>,
) -> Result<()> {
if confirmation != Some(backup_id) {
bail!(
"escribe `--confirm {backup_id}` para restaurarla: se sobrescribe el volumen entero y el servicio se reinicia"
);
}
println!(
" {} copia de seguridad previa, parando el servicio y restaurando…",
theme::ARROW.style(theme::muted()),
);
let backup = client.restore_backup(backup_id).await?;
println!(
" {} volumen restaurado desde {}",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
);
println!(
" {}",
"Antes de sobrescribir se guardó una copia del estado anterior.".style(theme::muted()),
);
Ok(())
}
pub async fn lock_backup(client: &ApiClient, backup_id: &str, locked: bool) -> Result<()> {
let backup = client.lock_backup(backup_id, locked).await?;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
if locked {
"guardada — la retención ya no la borrará"
} else {
"vuelve a estar sujeta a la retención"
},
);
Ok(())
}
pub async fn delete_backup(client: &ApiClient, backup_id: &str) -> Result<()> {
client.delete_backup(backup_id).await?;
println!(
" {} copia {} eliminada",
theme::CHECK.style(theme::success()),
backup_id.style(theme::strong()),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
fn refusal(code: &str) -> anyhow::Error {
ApiError {
status: StatusCode::NOT_FOUND,
code: code.to_owned(),
message: "template 'claude-code' does not exist".to_owned(),
}
.into()
}
#[test]
fn it_should_say_who_may_add_a_component_the_server_refused() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained
.to_string()
.contains("solo su dueño puede añadirlo"));
}
#[test]
fn it_should_name_the_component_it_could_not_add() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained.to_string().contains("claude-code"));
}
#[test]
fn it_should_keep_the_server_answer_as_the_cause() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained.downcast_ref::<ApiError>().is_some());
}
#[test]
fn it_should_leave_a_failure_that_is_not_a_missing_component_alone() {
let explained = explain_template_refusal(refusal("conflict"), "claude-code");
assert!(!explained.to_string().contains("dueño"));
}
}