Un PaaS sin CLI es un juguete. Los paneles son excelentes para exploracion y monitoreo, pero cuando estas en una terminal, conectado por SSH a un servidor, o escribiendo scripts para un pipeline CI/CD, necesitas comandos. Necesitas sh0 deploy my-app --wait y sh0 logs my-app --follow y sh0 env my-app set DATABASE_URL=postgres://.... Necesitas el mismo poder que tienes en el navegador, sin abrir un navegador.
La Fase 15 de la construccion de sh0 -- 12 de marzo de 2026, dia uno del proyecto -- fue el cliente CLI. Lo construimos el primer dia porque sabiamos que lo usariamos constantemente para probar cada fase subsecuente. La CLI no fue un extra agradable anadido al final; fue un ciudadano de primera clase desde el principio, y se convirtio en una de las herramientas mas utilizadas en nuestro flujo de trabajo de desarrollo.
Arquitectura: Sh0Client y Config
La CLI es un cliente delgado. No incorpora el servidor, el motor Docker ni la base de datos. Se comunica con un servidor sh0 en ejecucion a traves de HTTP y WebSocket. Todo el cliente vive en el binario crates/sh0 -- el mismo binario que ejecuta el servidor con sh0 serve.
La configuracion se carga desde ~/.sh0/config.toml o variables de entorno:
rustpub struct Sh0Client {
base_url: String,
token: String,
http: reqwest::Client,
}
impl Sh0Client {
pub fn from_config() -> Result<Self> {
// Priority: env vars > config file
let base_url = std::env::var("SH0_API_URL")
.or_else(|_| config_value("api_url"))
.unwrap_or_else(|_| "http://localhost:3000".into());
let token = std::env::var("SH0_API_TOKEN")
.or_else(|_| config_value("api_token"))?;
Ok(Self {
base_url,
token,
http: reqwest::Client::new(),
})
}
}El formato del archivo de configuracion es minimo:
tomlapi_url = "https://sh0.example.com"
api_token = "sh0_a1b2c3d4e5f6..."Las variables de entorno tienen prioridad, lo cual es critico para CI/CD. Un flujo de trabajo de GitHub Actions puede establecer SH0_API_URL y SH0_API_TOKEN como secretos, y cada comando sh0 en el pipeline se autenticara automaticamente.
El Sh0Client proporciona metodos HTTP tipados (get, post, delete, get_paginated) que manejan los patrones comunes: inyeccion de token Bearer, deserializacion JSON, desempaquetado automatico del envoltorio { "data": T } y traduccion de errores de API en mensajes amigables para el usuario.
Los 10 comandos
1. sh0 apps -- Listar aplicaciones
$ sh0 apps
NAME STATUS BRANCH PORT REPLICAS UPDATED
my-api running main 3000 2 5m ago
wordpress running - 80 1 2h ago
staging-fe building develop 5173 1 30s agoFormato de tabla con columnas de ancho automatico, estado con codigo de color (verde para running, amarillo para building, rojo para failed, gris para stopped). La bandera --json produce JSON crudo para scripting.
2. sh0 deploy -- Activar despliegue
$ sh0 deploy my-api -m "Fix payment endpoint" --wait
Triggered deployment for my-api
Waiting for deployment to complete...
Status: cloning...
Status: building...
Status: deploying...
Status: running
Deployment successful (42s)La bandera -m adjunta un mensaje al registro de despliegue. La bandera --wait consulta el estado del despliegue cada 2 segundos hasta que alcanza "running" o "failed". Sin --wait, el comando regresa inmediatamente despues de activar -- util para despliegues fire-and-forget en CI.
3. sh0 logs -- Transmitir logs via WebSocket
$ sh0 logs my-api --follow --tail 100
2026-03-12T14:32:01Z [my-api] Server started on port 3000
2026-03-12T14:32:05Z [my-api] Connected to database
2026-03-12T14:32:06Z [my-api] GET /health 200 2msLa transmision de logs usa tokio-tungstenite para una conexion WebSocket en tiempo real al servidor, que a su vez lee del flujo de logs del contenedor Docker. La bandera --follow mantiene la conexion abierta para seguimiento en vivo. La bandera --tail N solicita solo las ultimas N lineas en la conexion inicial.
Este fue uno de los comandos mas complejos de implementar porque las conexiones WebSocket necesitan gestion de ciclo de vida adecuada -- manejar desconexiones del servidor, autenticacion via la cabecera Sec-WebSocket-Protocol (movimos el token fuera del query string de la URL por seguridad) y cierre graceful con Ctrl+C.
4. sh0 env -- Gestion de variables de entorno
$ sh0 env my-api list
KEY VALUE BUILD
DATABASE_URL ******** false
API_SECRET ******** false
NODE_ENV production false
$ sh0 env my-api set DATABASE_URL=postgres://user:pass@db:5432/mydb
Set DATABASE_URL on my-api
$ sh0 env my-api delete API_SECRET
Deleted API_SECRET from my-apiEl comando env usa el derive de subcomandos de clap para soportar las acciones list, set y delete. El comando list enmascara los valores por defecto -- --reveal muestra los valores descifrados (requiere rol developer+). El comando set parsea el formato KEY=VALUE. La bandera --build marca una variable como de tiempo de compilacion (inyectada durante la construccion de la imagen Docker) en lugar de tiempo de ejecucion.
5. sh0 check -- Verificacion de salud local
$ sh0 check ./my-project
Score: 85/100 (Good)
BLOCKING (1)
SEC001: Hardcoded API key in src/config.ts:14
WARNINGS (2)
NODE001: Missing "start" script in package.json
BUILD002: No .dockerignore file found
INFO (3)
CONFIG001: Port 3000 detected from package.json
...A diferencia de los otros comandos, check se ejecuta completamente en local. Usa la funcion sh0_builder::health::check_health -- el mismo analisis de salud de codigo que se ejecuta durante el pipeline de despliegue. Los hallazgos se agrupan por severidad con salida coloreada: rojo para bloqueantes, amarillo para advertencias, verde para auto-corregibles, azul para informativos.
Este es el unico comando que no necesita un servidor en ejecucion. Un desarrollador puede ejecutar sh0 check . en el directorio de su proyecto antes de hacer push, detectando los mismos problemas que el pipeline de despliegue detectaria.
6. sh0 ssh -- Shell en el contenedor
$ sh0 ssh my-api
root@a1b2c3d4e5f6:/app#
$ sh0 ssh my-api "cat /app/package.json"
{ "name": "my-api", "version": "1.0.0" ... }El comando ssh no usa SSH realmente. Valida que el contenedor de la aplicacion esta en ejecucion, luego ejecuta docker exec -it sh0-{app} /bin/sh (o /bin/bash si esta disponible). Las banderas -it proporcionan una sesion TTY interactiva. Si se proporciona un argumento de comando, ejecuta ese comando de forma no interactiva y devuelve la salida.
Este es la valvula de escape. Cuando los logs no te dicen suficiente, cuando necesitas inspeccionar el sistema de archivos, verificar un proceso en ejecucion o probar la conectividad de red desde dentro del contenedor -- sh0 ssh te deja en el entorno exacto donde tu aplicacion se esta ejecutando.
7. sh0 status -- Estado del servidor o aplicacion
$ sh0 status
sh0 v0.1.0
Uptime: 14d 6h 32m
Apps: 12 (10 running, 1 building, 1 stopped)
Docker: connected
OS: Linux 6.1.0 (Debian 12)
$ sh0 status my-api
my-api (running)
Branch: main
Port: 3000
Replicas: 2
Last deploy: 5h ago (commit a1b2c3d)
Created: 2026-03-12Sin argumento de aplicacion, status muestra informacion a nivel de servidor: version, tiempo activo, conteos agregados de aplicaciones, conectividad Docker. Con un argumento de aplicacion, muestra los detalles de la aplicacion incluyendo la informacion de su ultimo despliegue.
8. sh0 scale -- Gestion de replicas
$ sh0 scale my-api 3
Scaled my-api to 3 replicas
$ sh0 scale my-api --status
my-api: 3 replicas (autoscale: off)Escalado horizontal manual. El numero establece el conteo de replicas deseado. La bandera --auto habilita el autoescalado con umbrales de CPU/memoria. La bandera --status muestra la configuracion de escalado actual.
9. sh0 cron -- Gestion de tareas cron
$ sh0 cron ls
ID APP SCHEDULE COMMAND STATUS LAST RUN
1 my-api */5 * * * * node cleanup.js enabled 2m ago
2 my-api 0 2 * * * pg_dump mydb > ... enabled 22h agoListar, crear, activar, ver historial de ejecucion y eliminar tareas cron. El subcomando trigger ejecuta una tarea inmediatamente, evitando la programacion -- util para pruebas.
10. sh0 templates -- Despliegue de plantillas
$ sh0 templates list --category databases
NAME CATEGORY DESCRIPTION
postgresql databases PostgreSQL 16 with persistent storage
mysql databases MySQL 8 with persistent storage
redis databases Redis 7 in-memory data store
...
$ sh0 templates deploy postgresql --app-name my-db --var POSTGRES_PASSWORD=secret
Deploying template: postgresql
Created app: my-db (running)Explorar y desplegar desde las 119 plantillas integradas. La bandera --var KEY=VALUE anula las variables de plantilla -- sin ella, los secretos se auto-generan.
Resolucion de nombres de aplicacion
Una decision de diseno que vale la pena destacar: como la CLI resuelve las referencias a aplicaciones. Los usuarios pueden pasar un ID de aplicacion (un UUID) o un nombre de aplicacion:
rustpub async fn resolve_app(&self, name_or_id: &str) -> Result<AppInfo> {
// Try as ID first (fast, exact match)
if let Ok(app) = self.get::<AppInfo>(&format!("/api/v1/apps/{}", name_or_id)).await {
return Ok(app);
}
// Fallback: search by name
let apps: Vec<AppInfo> = self.get_paginated("/api/v1/apps", 1, 200).await?;
apps.into_iter()
.find(|a| a.name == name_or_id)
.ok_or_else(|| anyhow!("App '{}' not found", name_or_id))
}Intentar la entrada como ID primero (una sola llamada API). Si falla, buscar por nombre en todas las aplicaciones. Esto significa que sh0 deploy my-api y sh0 deploy 550e8400-e29b-41d4-a716-446655440000 ambos funcionan, y el caso comun (nombre) toma como maximo dos llamadas API.
Formato de terminal
El modulo output.rs proporciona helpers de formato que hacen la salida de la CLI legible sin ser ruidosa:
rustpub fn format_status(status: &str) -> ColoredString {
match status {
"running" => status.green(),
"building" | "deploying" | "cloning" => status.yellow(),
"failed" => status.red(),
"stopped" | "inactive" => status.dimmed(),
_ => status.normal(),
}
}
pub fn format_time_ago(timestamp: &str) -> String {
let dt = DateTime::parse_from_rfc3339(timestamp).ok();
match dt {
Some(dt) => {
let duration = Utc::now() - dt.with_timezone(&Utc);
if duration.num_seconds() < 60 { format!("{}s ago", duration.num_seconds()) }
else if duration.num_minutes() < 60 { format!("{}m ago", duration.num_minutes()) }
else if duration.num_hours() < 24 { format!("{}h ago", duration.num_hours()) }
else { format!("{}d ago", duration.num_days()) }
}
None => timestamp.to_string(),
}
}La funcion print_table calcula automaticamente los anchos de columna a partir de los datos, asi que las tablas siempre se ajustan al contenido sin anchos codificados. Los valores de estado tienen codigo de color. Las marcas de tiempo son relativas ("5m ago" en lugar de "2026-03-12T14:32:01Z"). La bandera --json en la mayoria de los comandos evita todo el formato y produce JSON crudo, que es lo que quieres cuando canalizas a jq o parseas en un script.
La CLI como infraestructura de pruebas
Mas alla de su rol como herramienta orientada al usuario, la CLI se convirtio en infraestructura critica de pruebas durante el desarrollo. Cada nuevo endpoint de API fue probado con la CLI antes de que se construyera el panel. El pipeline de despliegue se depuro con sh0 deploy --wait. La transmision de logs por WebSocket se valido con sh0 logs --follow. El cifrado de variables de entorno se probo con sh0 env set y sh0 env list --reveal.
Construir la CLI el dia uno -- antes del panel, antes de las plantillas, antes del sistema de escalado -- fue una de las mejores decisiones del proyecto. Nos dio una interfaz rapida y scripteable para cada funcionalidad a medida que se construia, y detecto problemas de diseno de API que no habriamos notado si solo hubieramos probado a traves de clientes HTTP como curl o Postman.
La CLI tambien encarna un principio que nos importa: un PaaS deberia ser operable completamente desde la terminal. No todos los usuarios quieren un panel. Algunos usuarios viven en tmux, despliegan desde CI y gestionan su infraestructura a traves de scripts. La CLI asegura que son ciudadanos de primera clase, no usuarios de segunda clase de un producto centrado en el panel.
Esta es la Parte 33 de la serie "Como construimos sh0.dev". A continuacion: aplicacion de licencias -- como implementamos un sistema freemium de 3 niveles en Rust, funcionalidades restringidas en 10 archivos de manejadores, y decisiones de precios para mercados africanos.