sh0 ya tenía un CLI. Diez comandos, construidos el primer día, replicando cada acción del dashboard. Desplegar, logs, variables de entorno, health checks, SSH a contenedores. Pero había un vacío que ninguno de esos comandos llenaba.
Un desarrollador clona un repo. Escribe código. Lo quiere en línea. En ese momento, no debería tener que abrir un navegador, navegar a un dashboard, crear una app, configurar un repositorio Git, esperar un webhook, y luego disparar un build. Debería escribir un comando y obtener una URL.
Ese comando es sh0 push.
$ sh0 push
Pushing my-app
Detected nodejs (Next.js) -- 85/100 health
42 files (2.3 MB) packaged
Uploading OK 0.8s
Building OK 32.4s
[ok] Live in 35.3s
-> https://my-app.sh0.appSeis líneas de salida. Cero configuración. De directorio local a URL en vivo en 35 segundos.
Este artículo explica cada capa de la implementación -- desde el empaquetado de archivos hasta el polling de despliegue -- y las decisiones de seguridad que dieron forma al diseño final.
El problema: demasiados pasos entre código y URL
Antes de sh0 push, desplegar en sh0 requería cinco pasos:
- Crear una app en el dashboard
- Conectar un repositorio Git
- Configurar los ajustes de build
- Push a Git
- Esperar a que el webhook dispare un build
Esto está bien para flujos de producción. Es terrible para el momento en que un desarrollador piensa "quiero ver esto en línea." Ese momento exige inmediatez. Cada paso extra es fricción, y la fricción mata la adopción.
Estudiamos lo que Vercel hizo con vercel --prod, lo que Fly.io hizo con fly deploy, y lo que Instapods demostró con instapods deploy my-app. El patrón siempre es el mismo: detectar el proyecto, empaquetar los archivos, subirlos, construir en el servidor, devolver una URL.
El insight fue que sh0 ya tenía el 90% de la infraestructura del lado del servidor. El endpoint de subida existía. La detección de stack existía. El pipeline de build existía. La auto-creación de dominio existía. Lo que faltaba era el pegamento del CLI -- un solo comando que orquestara el flujo completo.
Paso 1: Detección de stack (reutilizando lo que teníamos)
El sistema de build de sh0 ya incluye un detector de stack que reconoce 19 stacks tecnológicos examinando archivos del proyecto:
rustlet stack_result = detect_stack(&project_path, ".").await;
if let Some(ref stack) = stack_result {
let health = check_health(&project_path, ".").await;
print_step(&format!(
"Detected {} ({}) -- {}/100 health",
stack.stack_type, stack.framework, health.score
));
}El detector lee package.json, Cargo.toml, requirements.txt, go.mod, composer.json, y docenas de otros marcadores de proyecto. Devuelve el tipo de stack, el framework, el gestor de paquetes, y el puerto predeterminado. El verificador de salud luego ejecuta 34 reglas contra el proyecto -- verificando Dockerfiles, .dockerignore, configuración de variables de entorno, y señales de preparación para producción.
Ambas llamadas están envueltas en .ok() para que push funcione incluso cuando la detección falla. Un proyecto sin stack reconocible aún puede ser pushed -- el servidor recurre a la detección basada en Dockerfile.
Paso 2: Empaquetado de archivos en un ZIP
Aquí es donde las decisiones de seguridad empiezan a importar. El CLI crea un archivo ZIP en memoria del directorio del proyecto, pero debe excluir archivos que nunca deberían salir de la máquina del desarrollador.
La jerarquía de ignorados tiene tres capas:
.sh0ignore-- Exclusiones específicas del proyecto (prioridad más alta).dockerignore-- Convención Docker (fallback).gitignore-- Convención Git (último recurso)- Patrones siempre excluidos -- 21 patrones codificados que se excluyen independientemente
La lista de siempre excluidos fue objeto del primer hallazgo crítico de auditoría. Esto es lo que entregamos:
rustpub(crate) const ALWAYS_EXCLUDE: &[&str] = &[
".git", "node_modules", ".next", ".nuxt", ".output",
"target", "__pycache__", ".venv", "venv", ".tox",
"dist", "build", ".svelte-kit", ".turbo", ".cache",
".DS_Store", "*.pyc", "*.pyo", ".sh0",
".env*", // Crítico: wildcard, no entradas individuales
".idea", ".vscode",
];La implementación original listaba .env, .env.local, .env.production, .env.development como entradas separadas. El auditor inmediatamente señaló esto: .env.staging, .env.test, .env.custom-anything se filtrarían. La corrección fue un solo patrón wildcard .env* que atrapa cada variante.
Guardias de tamaño
Después de la corrección .env*, la segunda auditoría agregó límites de recursos del lado del cliente:
rustconst MAX_ARCHIVE_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
const MAX_FILE_COUNT: u64 = 50_000;
// Durante la creación del ZIP:
cumulative_size += content.len() as u64;
file_count += 1;
if cumulative_size > MAX_ARCHIVE_SIZE {
anyhow::bail!("Archive exceeds 500 MB limit");
}
if file_count > MAX_FILE_COUNT {
anyhow::bail!("Archive exceeds 50,000 file limit");
}Sin estas guardias, un desarrollador podría intentar accidentalmente hacer push de un directorio con artefactos de build o archivos de datos, consumiendo toda la memoria disponible durante la creación del ZIP. El servidor ya valida el tamaño de subida, pero detectarlo en el cliente previene una mala experiencia.
Paso 3: El cliente de subida
Subir un archivo ZIP no es lo mismo que hacer una llamada API JSON. El cliente HTTP predeterminado tiene un timeout de 30 segundos -- suficiente para solicitudes API, insuficiente para subir un archivo de 200 MB con una conexión lenta.
rustpub fn upload_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.context("Failed to build upload HTTP client")
}La implementación original tragaba errores del builder y recurría a un cliente no configurado de 30 segundos. Esto fue señalado como Importante en la primera auditoría: un desarrollador subiendo un proyecto grande experimentaría un timeout silencioso sin indicación de por qué. La corrección fue hacer que upload_client() devuelva Result<reqwest::Client>, forzando a los llamadores a manejar el error explícitamente.
La subida usa POST multipart:
rustlet form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(zip_data)
.file_name("source.zip")
.mime_str("application/zip")?
)
.text("name", app_name.clone())
.text("port", port.to_string());
// App nueva vs re-push a app existente
let url = if let Some(app_id) = existing_app_id {
format!("{}/api/v1/apps/{}/upload", base_url, app_id)
} else {
format!("{}/api/v1/apps/upload", base_url)
};Dos endpoints, uno para crear una nueva app y otro para re-subir a una existente. El endpoint de re-subida fue código nuevo del lado del servidor: reutiliza el registro de app existente, crea un nuevo despliegue con triggered_by: "cli-push", e incluye un guardia de despliegue concurrente que devuelve HTTP 409 si un build ya está en progreso.
Paso 4: Polling del estado de build
Después de la subida, el servidor devuelve un ID de despliegue. El CLI hace polling del estado cada 1,5 segundos, transmitiendo nuevas líneas de log incrementalmente:
rustlet spinner = create_spinner("Building");
let mut last_log_len = 0;
loop {
let deployment = client.get_deployment(&deploy_id).await?;
// Transmitir nuevas líneas de log
if let Some(ref log) = deployment.build_log {
if log.len() > last_log_len {
let new_content = &log[last_log_len..];
for line in new_content.lines() {
update_phase_from_log(line, &spinner);
}
last_log_len = log.len();
}
}
match deployment.status.as_str() {
"running" => {
spinner.finish_with_message("OK");
break; // Éxito
}
"failed" => {
spinner.finish_with_message("FAILED");
return Err(anyhow!("Deployment failed"));
}
_ => {} // Aún construyendo, continuar polling
}
tokio::time::sleep(Duration::from_millis(1500)).await;
}La limpieza del spinner fue otro hallazgo de auditoría. El código original no limpiaba el spinner en errores de red durante el polling, dejando la terminal en estado corrupto. La corrección fue un bloque match explícito que finaliza el spinner en cada ruta de salida.
Paso 5: El archivo de enlace
En un despliegue exitoso, el CLI guarda un archivo .sh0/link.json en el directorio del proyecto:
json{
"app_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"app_name": "my-app",
"server_url": "https://sh0.example.com"
}Este archivo sirve el mismo propósito que el directorio .vercel/ de Vercel: vincula un directorio local a una app remota. La próxima vez que el desarrollador ejecute sh0 push, el CLI lee el archivo de enlace y re-despliega en la misma app en lugar de crear una nueva.
La operación de escritura es atómica: el CLI escribe a un archivo temporal y luego llama std::fs::rename, que es atómica en sistemas POSIX. Esto previene corrupción si el proceso se interrumpe durante la escritura.
El problema del nombre de app
Derivar el nombre de app del nombre del directorio suena simple hasta que consideras los casos extremos. La función original sanitize_app_name usaba char::is_alphanumeric() para filtrar caracteres -- pero is_alphanumeric() acepta Unicode. Un desarrollador con un directorio nombrado en caracteres chinos o árabes pasaría la sanitización del lado del cliente, solo para fallar con un error confuso de validación del servidor (el servidor requiere nombres solo ASCII).
La auditoría de Ronda 2 detectó esto:
rust// Antes (roto): acepta Unicode
name.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect()
// Después (correcto): solo ASCII
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect()Una corrección de un carácter -- is_alphanumeric a is_ascii_alphanumeric -- que previene una clase de errores confusos para desarrolladores en todo el mundo.
Lado del servidor: el endpoint de re-subida
El nuevo endpoint POST /api/v1/apps/:id/upload maneja re-push a apps existentes. La pieza más interesante es el guardia de despliegue concurrente:
rust// Verificar despliegues activos
if Deployment::has_active_by_app_id(&conn, app_id)? {
return Err(ApiError::Conflict(
"A deployment is already in progress for this app".into()
));
}La consulta has_active_by_app_id verifica seis estados activos: queued, building, pushing, starting, pulling, uploading. Si algún despliegue está en uno de estos estados, el endpoint devuelve HTTP 409 Conflict en lugar de iniciar un segundo build. Sin esta guardia, dos comandos sh0 push rápidos podrían crear despliegues que compiten entre sí.
La trampa de la exención CSRF
La API de sh0 usa protección CSRF en solicitudes que cambian estado. Los endpoints de subida necesitaban estar exentos (usan autenticación Bearer token del CLI, no cookies de navegador). La exención original usaba:
rustif path.contains("/upload") {
// Omitir verificación CSRF
}Este fue el hallazgo Crítico C-2: cualquier ruta con "upload" en cualquier parte de la ruta omitiría CSRF. Si un futuro desarrollador agregara /api/v1/settings/upload-config, silenciosamente omitiría la protección CSRF. La corrección fue coincidencia exacta de ruta:
rustif path == "/api/v1/apps/upload"
|| (path.starts_with("/api/v1/apps/") && path.ends_with("/upload")) {
// Omitir verificación CSRF -- solo endpoints de subida
}Los resultados de auditoría
La Fase 1 pasó por dos rondas de auditoría independientes:
Ronda 1: 3 Críticos, 6 Importantes, 5 Menores.
- Filtración de secretos .env* (Crítico)
- Exención CSRF demasiado amplia (Crítico)
- process::exit(1) en contexto async (Crítico)
- Cliente de subida traga errores (Importante)
- Sin guardias de tamaño/conteo ZIP (Importante)
- Escritura no atómica de archivo de enlace (Importante)
- Sin guardia de despliegue concurrente (Importante)
- Corrupción de terminal por spinner (Importante)
- Rutas OpenAPI faltantes (Importante)
Ronda 2: Verificó todas las 9 correcciones de Ronda 1, encontró 2 problemas Importantes adicionales.
- Unicode en sanitize_app_name (Importante)
- Detección de ZIP vacío usando longitud de bytes en lugar de conteo de archivos (Importante)
Cada hallazgo Crítico e Importante fue corregido. El código pasó de 36 tests a 37, con un nuevo test específicamente para coincidencia wildcard .env*.
Por qué esto importa
sh0 push no es técnicamente complejo. Es creación de ZIP, subida HTTP y polling. Cualquier desarrollador podría escribirlo en un fin de semana.
Lo que lo hace difícil es acertar los detalles. La filtración .env* habría enviado secretos al servidor. La exención CSRF habría debilitado la seguridad para cada ruta futura. El nombre de app Unicode habría producido errores confusos para desarrolladores en países con alfabetos no latinos. La escritura no atómica del archivo de enlace habría corrompido el estado al presionar Ctrl+C.
Estos son los detalles que separan una herramienta de despliegue de una herramienta de despliegue de producción. Y todos fueron detectados no por el desarrollador que escribió el código, sino por auditores independientes revisándolo con ojos frescos.
Por eso sh0 usa una metodología de auditoría multi-sesión: construir, auditar, auditar, aprobar. El constructor optimiza para funcionalidades. Los auditores optimizan para corrección. La metodología converge en ambas.
Siguiente en la serie: De 10 comandos a 30: el sprint de ergonomía para desarrolladores -- Cuatro nuevos comandos en una sesión: sh0 init, sh0 link, sh0 open y sh0 config.