diff --git a/specs/02_PYMESBOT_INSTALLER_SPEC.md b/specs/02_PYMESBOT_INSTALLER_SPEC.md new file mode 100644 index 0000000..b416ff1 --- /dev/null +++ b/specs/02_PYMESBOT_INSTALLER_SPEC.md @@ -0,0 +1,1476 @@ +# PymesBot Installer — Especificación Técnica Completa + +> **Documento para construir el sistema de instalación web** +> Versión 1.0 · Febrero 2026 · RVConsultas +> +> Este documento describe **exclusivamente** el sistema de instalación (`pymesbot-installer`). +> Es un proyecto separado del backend principal de PymesBot. +> +> **INSTRUCCIÓN PARA LA IA:** +> Leé este documento completo antes de escribir una sola línea de código. +> Cada sección es importante. No omitas nada. No asumas nada que no esté escrito acá. +> Si algo es ambiguo, implementá la opción más simple y segura. + +--- + +## ÍNDICE + +1. [Qué es el instalador](#1-qué-es-el-instalador) +2. [Cómo se usa](#2-cómo-se-usa) +3. [Estructura de archivos completa](#3-estructura-de-archivos-completa) +4. [Stack tecnológico](#4-stack-tecnológico) +5. [El archivo que descarga el operador](#5-el-archivo-que-descarga-el-operador) +6. [Módulo detect.py — detección del entorno](#6-módulo-detectpy--detección-del-entorno) +7. [Módulo installer.py — instalación base](#7-módulo-installerpy--instalación-base) +8. [Módulo client_setup.py — alta de cliente](#8-módulo-client_setuppy--alta-de-cliente) +9. [Módulo templates_engine.py — generador de configs](#9-módulo-templates_enginepy--generador-de-configs) +10. [Backend FastAPI — rutas y WebSocket](#10-backend-fastapi--rutas-y-websocket) +11. [El wizard HTML — especificación completa](#11-el-wizard-html--especificación-completa) +12. [Templates Jinja2 que genera el instalador](#12-templates-jinja2-que-genera-el-instalador) +13. [Seguridad](#13-seguridad) +14. [Testing y validación](#14-testing-y-validación) +15. [Orden de construcción recomendado](#15-orden-de-construcción-recomendado) + +--- + +## 1. Qué es el instalador + +El instalador es un **contenedor Docker temporal** que: + +1. Se levanta en el servidor con `docker compose up -d` +2. Expone una interfaz web en `http://IP-DEL-SERVIDOR/install` +3. El operador (Guillermo) abre esa URL en su navegador, responde preguntas +4. El instalador detecta el estado del servidor y ejecuta las acciones necesarias +5. Al terminar, muestra las credenciales del cliente nuevo +6. Se apaga manualmente con `docker compose down` + +**Principio central:** el instalador detecta automáticamente si el servidor está limpio (primera vez) o si ya tiene infraestructura. El operador nunca tiene que saber en qué estado está el servidor. + +### Qué puede hacer el instalador + +**Modo A — VPS limpia (primera instalación):** +- Instalar Nginx, Certbot +- Crear la estructura de carpetas `/opt/pymesbot/` +- Crear la red Docker `pymesbot_net` +- Dar de alta el primer cliente + +**Modo B — Infraestructura existente, nuevo cliente:** +- Crear la carpeta del cliente con toda su estructura +- Generar todos los archivos de configuración +- Agregar el bloque de Nginx para el nuevo subdominio +- Emitir el certificado SSL +- Levantar los contenedores Docker del cliente +- Generar credenciales de acceso + +**Modo C — Cliente ya existente (conflicto):** +- Detectar el conflicto y avisarle al operador +- Ofrecer actualizar la configuración sin tocar la DB +- O cambiar el slug a uno disponible + +--- + +## 2. Cómo se usa + +### Paso 1: Descargar en el servidor + +```bash +# En el servidor (via SSH) +mkdir -p ~/pymesbot-install +cd ~/pymesbot-install +curl -O https://rvconsultas.com/release/pymesbot-installer/docker-compose.yml +``` + +### Paso 2: Levantar el instalador + +```bash +docker compose up -d +``` + +### Paso 3: Abrir el wizard en el navegador + +``` +http://IP-DEL-SERVIDOR/install?token=TOKEN_QUE_SE_IMPRIME_EN_LA_TERMINAL +``` + +> El token se imprime en la terminal al levantar el contenedor. Sin este token, `/install` devuelve 404. + +### Paso 4: Seguir el wizard + +6 pasos en el navegador. Ver sección 11 para el detalle de cada paso. + +### Paso 5: Apagar el instalador + +```bash +docker compose down +``` + +--- + +## 3. Estructura de archivos completa + +Este es el proyecto completo que hay que construir. Cada archivo mencionado acá tiene que existir. + +``` +pymesbot-installer/ +│ +├── docker-compose.yml ← EL ARCHIVO QUE DESCARGA EL OPERADOR +├── Dockerfile ← imagen del instalador +├── .dockerignore +│ +├── app/ ← código Python del backend del instalador +│ ├── main.py ← FastAPI app + todas las rutas +│ ├── detect.py ← detección del estado del servidor +│ ├── installer.py ← lógica de instalación de infraestructura base (Modo A) +│ ├── client_setup.py ← lógica de alta de cliente (Modo B y Modo A post-infra) +│ ├── templates_engine.py ← generador de archivos de config con Jinja2 +│ ├── security.py ← token de sesión, validación sudo +│ ├── state.py ← estado de la instalación en memoria +│ └── config_templates/ ← templates Jinja2 para generar configs +│ ├── nginx_site.conf.j2 +│ ├── docker-compose.client.yml.j2 +│ ├── picoclaw_config.json.j2 +│ ├── env_client.j2 +│ └── schema.sql ← schema SQL de la DB del cliente (NO es template, es static) +│ +├── static/ ← frontend del wizard +│ ├── index.html ← el wizard completo (todo en UN archivo HTML) +│ ├── style.css ← estilos del wizard +│ └── wizard.js ← lógica del wizard +│ +└── requirements.txt +``` + +**IMPORTANTE:** El `static/index.html` es el wizard completo. Todo el frontend en un solo archivo. No crear carpetas separadas por componentes. Debe funcionar sin CDN externo si es necesario (aunque puede usar Google Fonts). + +--- + +## 4. Stack tecnológico + +| Componente | Tecnología | Versión | Notas | +|-----------|-----------|---------|-------| +| Backend del instalador | Python + FastAPI | Python 3.11, FastAPI 0.110 | — | +| ASGI server | Uvicorn | 0.29 | — | +| Templates de config | Jinja2 | 3.x | Para generar nginx.conf, docker-compose.yml, etc. | +| Ejecución de comandos shell | `asyncio.create_subprocess_shell` | stdlib | Nunca usar `subprocess.run` síncrono dentro de async | +| Streaming de output al frontend | WebSocket (FastAPI nativo) | — | `ws://{IP}/install/ws/progress` | +| Frontend del wizard | HTML + CSS + JS vanilla | ES2020 | Sin frameworks, sin npm, sin build step | +| Fuentes web | Google Fonts (IBM Plex Mono + Space Grotesk) | — | Si no hay internet, fallback a monospace y sans-serif | +| Contenedor | Docker | 24+ | — | + +### Python packages (`requirements.txt`) + +``` +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +jinja2==3.1.3 +python-multipart==0.0.9 +python-dotenv==1.0.1 +httpx==0.27.0 +aiofiles==23.2.1 +secrets # stdlib, no instalar +``` + +--- + +## 5. El archivo que descarga el operador + +Este es el `docker-compose.yml` del instalador. Es el único archivo que el operador necesita descargar. + +```yaml +version: '3.8' + +services: + pymesbot_installer: + image: rvconsultas/pymesbot-installer:latest + # Alternativa para desarrollo local: + # build: + # context: . + # dockerfile: Dockerfile + container_name: pymesbot_installer + restart: "no" # NO reiniciar solo, el operador lo baja manualmente + ports: + - "80:8000" # wizard accesible en puerto 80 + volumes: + # Estos 4 volúmenes son CRÍTICOS. Sin ellos el instalador no puede actuar sobre el host. + - /var/run/docker.sock:/var/run/docker.sock # para ejecutar docker compose en el host + - /usr/bin/docker:/usr/bin/docker:ro # binario docker disponible en el contenedor + - /opt/pymesbot:/opt/pymesbot # carpeta de clientes + - /etc/nginx:/etc/nginx # configs de Nginx + - /usr/sbin/nginx:/usr/sbin/nginx:ro # para ejecutar nginx -s reload + environment: + - INSTALLER_MODE=production + # El token de seguridad se genera al arrancar y se imprime en los logs + # Ver el token con: docker logs pymesbot_installer +``` + +### Dockerfile del instalador + +```dockerfile +FROM python:3.11-slim + +LABEL maintainer="RVConsultas" +LABEL description="PymesBot Installer — wizard de instalación web" + +WORKDIR /installer + +# Instalar herramientas del sistema necesarias +# curl: para health checks +# sudo: para ejecutar comandos privilegiados con la contraseña del operador +RUN apt-get update && apt-get install -y \ + curl \ + sudo \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Copiar e instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar el código del instalador +COPY app/ ./app/ +COPY static/ ./static/ + +# Exponer puerto +EXPOSE 8000 + +# Al arrancar: generar token, imprimirlo, y levantar uvicorn +CMD ["sh", "-c", "python app/main.py"] +``` + +### .dockerignore + +``` +__pycache__/ +*.pyc +*.pyo +.env +.git/ +*.md +tests/ +``` + +--- + +## 6. Módulo detect.py — detección del entorno + +Este módulo examina el servidor y devuelve un objeto con el estado de cada componente. + +### Función principal: `async def detectar_entorno() -> DetectionResult` + +```python +# app/detect.py + +import asyncio +import os +import json +from dataclasses import dataclass, field +from typing import List, Optional + +@dataclass +class ComponentStatus: + nombre: str + ok: bool + version: Optional[str] = None + detalle: Optional[str] = None + +@dataclass +class ClienteExistente: + slug: str + nombre: str + puerto: int + activo: bool + +@dataclass +class DetectionResult: + docker_ok: bool + docker_version: Optional[str] + nginx_ok: bool + nginx_version: Optional[str] + certbot_ok: bool + certbot_version: Optional[str] + pymesbot_dir_ok: bool + red_docker_ok: bool + clientes_existentes: List[ClienteExistente] + modo: str # "A" | "B" | informational + mensaje_modo: str + siguiente_puerto: int + componentes: List[ComponentStatus] +``` + +### Lógica de detección — implementar en este orden exacto + +```python +async def detectar_entorno() -> DetectionResult: + """ + Detecta el estado del servidor y determina el modo de instalación. + Nunca lanza excepciones. Si algo falla, marca ese componente como no-ok. + """ + + # 1. Detectar Docker + # Ejecutar: docker --version + # Parsear la versión del output + # Si falla: docker_ok = False + + # 2. Detectar Nginx + # Ejecutar: nginx -v 2>&1 + # (nginx imprime la versión en stderr, por eso el 2>&1) + # Si falla: nginx_ok = False + + # 3. Detectar Certbot + # Ejecutar: certbot --version + # Si falla: certbot_ok = False + + # 4. Detectar /opt/pymesbot + # os.path.exists("/opt/pymesbot") y os.path.isdir("/opt/pymesbot") + + # 5. Detectar red Docker pymesbot_net + # Ejecutar: docker network ls --filter name=pymesbot_net --format "{{.Name}}" + # Si el output contiene "pymesbot_net": ok + + # 6. Listar clientes existentes + # Listar directorios en /opt/pymesbot/ + # Excluir: "nginx", "scripts", archivos (no dirs), dirs que empiecen con "." + # Para cada cliente, leer su .env para obtener nombre y puerto + + # 7. Determinar modo + # Modo A: cualquiera de docker_ok, nginx_ok, pymesbot_dir_ok es False + # Modo B: todo ok, y el slug del nuevo cliente no existe + # Modo C: el slug ya existe (esto se detecta después cuando el operador ingresa el slug) + + # 8. Determinar siguiente puerto disponible + # Puertos usados = [c.puerto for c in clientes_existentes] + # Siguiente = próximo disponible empezando desde 8200 + # Verificar que el puerto no esté en uso: ss -tuln | grep :PUERTO +``` + +### Helper: ejecutar comandos de forma segura + +```python +async def run_cmd(cmd: str, timeout: int = 30) -> tuple[int, str, str]: + """ + Ejecuta un comando shell de forma asíncrona. + Returns: (return_code, stdout, stderr) + Nunca lanza excepciones. Si algo falla, devuelve (1, "", str(error)) + """ + try: + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=timeout + ) + return proc.returncode, stdout.decode(), stderr.decode() + except asyncio.TimeoutError: + return 1, "", f"Timeout después de {timeout}s" + except Exception as e: + return 1, "", str(e) +``` + +--- + +## 7. Módulo installer.py — instalación base + +Este módulo se usa solo en **Modo A** (VPS limpia). Instala la infraestructura base. + +### Función principal: `async def instalar_infraestructura(config, progress_callback)` + +El parámetro `progress_callback` es una función async que se llama para enviar eventos al frontend via WebSocket. + +```python +# Formato del evento de progreso +# progress_callback(evento: dict) → None + +# Eventos posibles: +{ + "type": "step_start", + "step_id": "nginx_install", + "label": "Instalando Nginx y Certbot..." +} + +{ + "type": "output", + "step_id": "nginx_install", + "line": "Reading package lists... Done" +} + +{ + "type": "step_done", + "step_id": "nginx_install", + "duration_ms": 4320 +} + +{ + "type": "step_error", + "step_id": "nginx_install", + "error": "E: Package 'nginx' has no installation candidate", + "hint": "Verificá que el servidor tenga acceso a internet y que los repositorios estén actualizados." +} +``` + +### Pasos de instalación base (Modo A) + +Ejecutar en este orden exacto. Si algún paso falla, detenerse y reportar el error con `step_error`. + +#### Paso A1: `apt_update` — Actualizar repositorios + +```bash +# Comando a ejecutar (con la contraseña sudo del operador): +echo '{SUDO_PASSWORD}' | sudo -S apt-get update -y +``` + +- Label para el frontend: `"Actualizando repositorios del sistema..."` +- Timeout: 120 segundos +- Si falla: hint = `"Verificá que el servidor tenga conexión a internet."` + +#### Paso A2: `install_packages` — Instalar Nginx y Certbot + +```bash +echo '{SUDO_PASSWORD}' | sudo -S apt-get install -y nginx certbot python3-certbot-nginx +``` + +- Label: `"Instalando Nginx y Certbot..."` +- Timeout: 300 segundos (puede tardar mucho descargando paquetes) +- Si falla: hint = `"Intentá correr manualmente: sudo apt-get install nginx certbot python3-certbot-nginx"` + +#### Paso A3: `create_dirs` — Crear estructura de carpetas + +```bash +echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/nginx/conf.d +echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/scripts +echo '{SUDO_PASSWORD}' | sudo -S chmod 755 /opt/pymesbot +``` + +- Label: `"Creando estructura de carpetas /opt/pymesbot..."` +- Timeout: 10 segundos +- Idempotente: `mkdir -p` no falla si ya existe + +#### Paso A4: `create_docker_network` — Crear red Docker + +```bash +docker network create pymesbot_net +``` + +**IMPORTANTE:** Este comando puede fallar con "network already exists". Eso NO es un error. Si falla con ese mensaje exacto, marcar como `step_done` igualmente. + +- Label: `"Creando red Docker pymesbot_net..."` +- Timeout: 15 segundos + +#### Paso A5: `nginx_base_config` — Configurar Nginx base + +Escribir el archivo `/etc/nginx/nginx.conf` base si no existe o si el existente no tiene la directiva `include /etc/nginx/conf.d/*.conf`. + +```bash +# Verificar si ya tiene el include +grep -q "include /etc/nginx/conf.d/" /etc/nginx/nginx.conf +``` + +Si ya lo tiene: marcar como done sin hacer nada. +Si no lo tiene: agregar la línea al bloque `http {}`. + +Después: +```bash +echo '{SUDO_PASSWORD}' | sudo -S nginx -t +echo '{SUDO_PASSWORD}' | sudo -S systemctl enable nginx +echo '{SUDO_PASSWORD}' | sudo -S systemctl start nginx +``` + +- Label: `"Configurando Nginx base..."` +- Si `nginx -t` falla: `step_error` con el output del error como hint + +#### Paso A6: Continuar con Modo B + +Una vez terminada la instalación base, continuar directamente con el flujo de alta del primer cliente (llama a `client_setup.py`). + +--- + +## 8. Módulo client_setup.py — alta de cliente + +Este módulo da de alta un nuevo cliente. Se usa en Modo B y en la segunda parte del Modo A. + +### Input: clase `ClientConfig` + +```python +@dataclass +class ClientConfig: + slug: str # "castillo" — solo minúsculas, guiones, sin espacios + nombre: str # "Librería Castillo" + email: str # "dueño@gmail.com" + rubro: str # "libreria" | "kiosco" | "bazar" | "otro" + moneda: str # "ARS" | "USD" | "UYU" + dominio_base: str # "rvconsultas.com" + anthropic_api_key: str # "sk-ant-..." + sudo_password: str # contraseña sudo del operador (nunca loguear) + puerto: int # asignado por detect.py, ej: 8201 + admin_password: str # generada aleatoriamente + vendor_pin: str # generada aleatoriamente (4 dígitos) +``` + +### Función principal: `async def setup_cliente(config: ClientConfig, progress_callback)` + +Ejecutar estos pasos en orden. Si alguno falla, ejecutar rollback. + +#### Paso C1: `create_client_dirs` — Crear carpetas del cliente + +```bash +mkdir -p /opt/pymesbot/{slug}/picoclaw +mkdir -p /opt/pymesbot/{slug}/backend/templates +mkdir -p /opt/pymesbot/{slug}/data/uploads +``` + +- Label: `"Creando estructura de carpetas del cliente..."` +- Idempotente: `mkdir -p` no falla si ya existe + +#### Paso C2: `generate_configs` — Generar archivos de configuración + +Llamar a `templates_engine.py` para generar: + +1. `/opt/pymesbot/{slug}/.env` +2. `/opt/pymesbot/{slug}/docker-compose.yml` +3. `/opt/pymesbot/{slug}/picoclaw/config.json` + +- Label: `"Generando archivos de configuración..."` +- Si falla la escritura: verificar permisos de la carpeta + +#### Paso C3: `init_database` — Inicializar la base de datos SQLite + +Copiar el archivo `schema.sql` desde `app/config_templates/schema.sql` y ejecutarlo: + +```bash +sqlite3 /opt/pymesbot/{slug}/data/stock.db < /installer/app/config_templates/schema.sql +``` + +Después de crear la DB, insertar la configuración inicial del negocio: + +```sql +UPDATE config SET valor = '{nombre}' WHERE clave = 'nombre_negocio'; +UPDATE config SET valor = '{rubro}' WHERE clave = 'rubro'; +UPDATE config SET valor = '{moneda}' WHERE clave = 'moneda'; +UPDATE config SET valor = '{admin_password}' WHERE clave = 'admin_password'; +UPDATE config SET valor = '{vendor_pin}' WHERE clave = 'vendedor_pin'; +``` + +- Label: `"Inicializando base de datos SQLite..."` +- Timeout: 10 segundos + +#### Paso C4: `copy_backend` — Copiar el código del backend + +El instalador lleva consigo el código del backend de PymesBot dentro de la imagen Docker. +Copiar desde `/installer/pymesbot-backend/` a `/opt/pymesbot/{slug}/backend/`. + +```bash +cp -r /installer/pymesbot-backend/. /opt/pymesbot/{slug}/backend/ +``` + +**NOTA PARA LA IA:** El proyecto `pymesbot-backend` es un directorio incluido en la imagen del instalador. Contiene el código FastAPI del backend de PymesBot (el que se describe en `01_PYMESBOT_PROJECT_SPEC.md`). Al construir la imagen del instalador, este código debe estar incluido. + +- Label: `"Copiando código del backend..."` + +#### Paso C5: `configure_nginx` — Configurar Nginx para el subdominio + +1. Generar el archivo de configuración usando el template `nginx_site.conf.j2` +2. Escribirlo en `/etc/nginx/conf.d/{slug}.conf` +3. Validar: `nginx -t` +4. Recargar: `systemctl reload nginx` + +```bash +echo '{SUDO_PASSWORD}' | sudo -S nginx -t +echo '{SUDO_PASSWORD}' | sudo -S systemctl reload nginx +``` + +- Label: `"Configurando Nginx para {slug}.{dominio}..."` +- Si `nginx -t` falla: reportar el error exacto de nginx como hint + +#### Paso C6: `emit_ssl` — Emitir certificado SSL + +```bash +echo '{SUDO_PASSWORD}' | sudo -S certbot --nginx \ + -d {slug}.{dominio_base} \ + --non-interactive \ + --agree-tos \ + -m instalador@rvconsultas.com +``` + +- Label: `"Emitiendo certificado SSL para {slug}.{dominio_base}..."` +- Timeout: 120 segundos +- Si falla con "DNS problem": hint = `"Verificá que el registro DNS A de {slug}.{dominio_base} apunte a la IP de este servidor ({ip_publica}) y que haya propagado. Podés verificarlo en https://dnschecker.org"` +- Si falla con "too many certificates": hint = `"Let's Encrypt tiene un límite de certificados por semana. Esperá unos días o usá un subdominio diferente."` +- **Este paso es el que más puede fallar.** Manejar todos los errores posibles de certbot. + +#### Paso C7: `docker_up` — Levantar los contenedores + +```bash +docker compose -f /opt/pymesbot/{slug}/docker-compose.yml up -d --build +``` + +- Label: `"Levantando contenedores Docker..."` +- Timeout: 300 segundos (puede tardar si tiene que construir la imagen) +- `--build` fuerza rebuild si el código cambió + +#### Paso C8: `health_check` — Verificar que el cliente esté vivo + +Hacer GET a `https://{slug}.{dominio_base}/health` con reintentos: + +```python +for intento in range(1, 6): # 5 intentos + await asyncio.sleep(10) # esperar 10 segundos entre intentos + try: + response = await httpx.get(f"https://{slug}.{dominio_base}/health", timeout=10) + if response.status_code == 200: + # ¡Éxito! + break + except: + pass + # Si llegamos al intento 5 y falló: step_error +``` + +- Label: `"Verificando que el cliente esté respondiendo..."` +- Si falla después de 5 intentos: hint = `"Los contenedores levantaron pero el servicio no responde. Revisá los logs: docker logs pymesbot_{slug}"` + +#### Paso C9: `generate_credentials` — Guardar credenciales + +Guardar en `/opt/pymesbot/{slug}/credentials.json`: + +```json +{ + "url": "https://castillo.rvconsultas.com", + "admin_user": "admin", + "admin_password": "Xk7mQ2p", + "vendor_pin": "4821", + "instalado_el": "2026-02-14T15:30:00", + "cliente": "Librería Castillo" +} +``` + +- Label: `"Guardando credenciales..."` +- **Nunca loguear el contenido de este archivo** + +### Rollback ante fallo + +Si cualquier paso de C1 a C9 falla: + +```python +async def rollback_cliente(slug: str, sudo_password: str, progress_callback): + """ + Intenta deshacer todo lo que se hizo para el cliente. + No lanza excepciones aunque el rollback falle. + """ + # 1. Bajar los contenedores si están corriendo + await run_cmd(f"docker compose -f /opt/pymesbot/{slug}/docker-compose.yml down 2>/dev/null") + + # 2. Eliminar la carpeta del cliente + await run_cmd(f"echo '{sudo_password}' | sudo -S rm -rf /opt/pymesbot/{slug}") + + # 3. Eliminar el archivo de Nginx + await run_cmd(f"echo '{sudo_password}' | sudo -S rm -f /etc/nginx/conf.d/{slug}.conf") + + # 4. Recargar Nginx + await run_cmd(f"echo '{sudo_password}' | sudo -S systemctl reload nginx 2>/dev/null") + + # 5. Notificar al frontend + await progress_callback({ + "type": "rollback_done", + "message": f"Se revirtieron todos los cambios para el cliente '{slug}'." + }) +``` + +--- + +## 9. Módulo templates_engine.py — generador de configs + +Genera todos los archivos de configuración usando Jinja2. + +```python +# app/templates_engine.py + +from jinja2 import Environment, FileSystemLoader +import os + +TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "config_templates") +env = Environment(loader=FileSystemLoader(TEMPLATES_DIR)) + +def generar_env_cliente(config: ClientConfig) -> str: + """Genera el contenido del archivo .env del cliente""" + template = env.get_template("env_client.j2") + return template.render( + anthropic_api_key=config.anthropic_api_key, + cliente_nombre=config.nombre, + cliente_id=config.slug, + puerto=config.puerto, + admin_password=config.admin_password, + vendor_pin=config.vendor_pin, + dominio_base=config.dominio_base + ) + +def generar_docker_compose_cliente(config: ClientConfig) -> str: + """Genera el docker-compose.yml del cliente""" + template = env.get_template("docker-compose.client.yml.j2") + return template.render(slug=config.slug) + +def generar_picoclaw_config(config: ClientConfig) -> str: + """Genera el config.json de PicoClaw con el system prompt""" + template = env.get_template("picoclaw_config.json.j2") + return template.render( + nombre_negocio=config.nombre, + rubro=config.rubro + ) + +def generar_nginx_conf(config: ClientConfig) -> str: + """Genera el bloque de Nginx para el subdominio del cliente""" + template = env.get_template("nginx_site.conf.j2") + return template.render( + slug=config.slug, + dominio=config.dominio_base, + puerto=config.puerto + ) + +def escribir_archivo(ruta: str, contenido: str) -> None: + """Escribe el contenido en la ruta especificada. Crea directorios si no existen.""" + os.makedirs(os.path.dirname(ruta), exist_ok=True) + with open(ruta, "w", encoding="utf-8") as f: + f.write(contenido) +``` + +--- + +## 10. Backend FastAPI — rutas y WebSocket + +### Archivo principal: `app/main.py` + +```python +# app/main.py + +import asyncio +import secrets +import os +import time +from fastapi import FastAPI, WebSocket, HTTPException, Request, Depends +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from pydantic import BaseModel +from typing import Optional + +from .detect import detectar_entorno +from .installer import instalar_infraestructura +from .client_setup import setup_cliente, ClientConfig, rollback_cliente +from .security import validar_sudo, generar_credenciales, INSTALL_TOKEN +from .state import get_session, save_session + +app = FastAPI(title="PymesBot Installer", docs_url=None, redoc_url=None) + +# Montar archivos estáticos +app.mount("/static", StaticFiles(directory="static"), name="static") + +# ── TOKEN DE SEGURIDAD ──────────────────────────────────────────────────────── + +@app.on_event("startup") +async def startup(): + # Imprimir el token en la terminal para que el operador lo vea + print("\n" + "="*60) + print(f" 🔐 TOKEN DE ACCESO: {INSTALL_TOKEN}") + print(f" 🌐 URL: http://TU-SERVIDOR/install?token={INSTALL_TOKEN}") + print("="*60 + "\n") + # También guardar en un archivo para referencia + with open("/tmp/install_token.txt", "w") as f: + f.write(INSTALL_TOKEN) + + +# ── RUTAS DEL WIZARD ───────────────────────────────────────────────────────── + +def verificar_token(request: Request): + """Dependencia de FastAPI: verifica el token en query param o en cookie""" + token = request.query_params.get("token") or request.cookies.get("install_token") + if token != INSTALL_TOKEN: + raise HTTPException(status_code=404, detail="No encontrado") + return token + + +@app.get("/install") +async def wizard_html(token: str = Depends(verificar_token)): + """Sirve el HTML del wizard""" + return FileResponse("static/index.html") + + +@app.get("/install/detect") +async def api_detect(token: str = Depends(verificar_token)): + """Detecta el estado del servidor y devuelve el resultado como JSON""" + result = await detectar_entorno() + return result + + +@app.post("/install/validate-sudo") +async def api_validate_sudo(body: dict, token: str = Depends(verificar_token)): + """ + Valida la contraseña sudo. + Body: { "password": "..." } + Response: { "ok": true } | { "ok": false, "error": "..." } + """ + password = body.get("password", "") + if not password: + return JSONResponse({"ok": False, "error": "Contraseña vacía"}) + + ok, error = await validar_sudo(password) + if ok: + # Guardar en la sesión (en memoria, nunca en disco) + save_session("sudo_password", password) + return {"ok": True} + else: + return JSONResponse({"ok": False, "error": error}) + + +@app.post("/install/check-slug") +async def api_check_slug(body: dict, token: str = Depends(verificar_token)): + """ + Verifica si un slug está disponible. + Body: { "slug": "castillo" } + Response: { "disponible": true } | { "disponible": false, "razon": "..." } + """ + slug = body.get("slug", "").strip().lower() + + # Validar formato + import re + if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug) or len(slug) < 3: + return {"disponible": False, "razon": "El slug debe tener al menos 3 caracteres, solo minúsculas, números y guiones."} + + # Verificar que no existe + if os.path.exists(f"/opt/pymesbot/{slug}"): + return {"disponible": False, "razon": f"Ya existe un cliente con el slug '{slug}'."} + + return {"disponible": True} + + +@app.post("/install/start") +async def api_start_installation(body: dict, token: str = Depends(verificar_token)): + """ + Inicia la instalación. Guarda la config en sesión y devuelve el session_id para el WebSocket. + Body: ClientConfig completo + Response: { "session_id": "abc123" } + """ + sudo_password = get_session("sudo_password") + if not sudo_password: + raise HTTPException(status_code=400, detail="Sesión expirada. Volvé al paso de autenticación.") + + # Generar credenciales aleatorias + admin_password = secrets.token_urlsafe(8) + vendor_pin = str(secrets.randbelow(9000) + 1000) # 4 dígitos, no empieza con 0 + + config = ClientConfig( + slug=body["slug"], + nombre=body["nombre"], + email=body.get("email", ""), + rubro=body.get("rubro", "libreria"), + moneda=body.get("moneda", "ARS"), + dominio_base=body["dominio_base"], + anthropic_api_key=body["anthropic_api_key"], + sudo_password=sudo_password, + puerto=body["puerto"], # asignado por el frontend (viene del detect) + admin_password=admin_password, + vendor_pin=vendor_pin + ) + + session_id = secrets.token_hex(8) + save_session(session_id, { + "config": config, + "modo": body.get("modo", "B"), + "status": "pending" + }) + + return {"session_id": session_id} + + +@app.websocket("/install/ws/{session_id}") +async def ws_progress(websocket: WebSocket, session_id: str): + """ + WebSocket de progreso. El frontend se conecta acá para recibir eventos en tiempo real. + """ + await websocket.accept() + + session = get_session(session_id) + if not session: + await websocket.send_json({"type": "error", "error": "Sesión no encontrada"}) + await websocket.close() + return + + config = session["config"] + modo = session["modo"] + + async def progress_callback(evento: dict): + """Envía un evento al frontend via WebSocket""" + try: + await websocket.send_json(evento) + except: + pass # Si el WebSocket se cerró, ignorar + + try: + # Modo A: primero instalar infraestructura base, luego el cliente + if modo == "A": + await instalar_infraestructura(config, progress_callback) + + # Siempre dar de alta el cliente (tanto Modo A como Modo B) + await setup_cliente(config, progress_callback) + + # Enviar evento final de éxito + await websocket.send_json({ + "type": "done", + "url": f"https://{config.slug}.{config.dominio_base}", + "credentials": { + "admin_user": "admin", + "admin_password": config.admin_password, + "vendor_pin": config.vendor_pin, + "cliente": config.nombre + } + }) + + except Exception as e: + # Error inesperado: intentar rollback y notificar + await progress_callback({ + "type": "fatal_error", + "error": str(e), + "hint": "Error inesperado. Revisá los logs del instalador." + }) + # Intentar rollback + try: + await rollback_cliente(config.slug, config.sudo_password, progress_callback) + except: + pass + finally: + await websocket.close() +``` + +--- + +## 11. El wizard HTML — especificación completa + +El wizard es un único archivo HTML (`static/index.html`). Contiene HTML, CSS y JavaScript. + +### Diseño visual + +**Paleta de colores:** +```css +--bg: #080E1A; /* fondo principal */ +--bg2: #0D1526; /* fondo secundario (sidebar, header) */ +--bg3: #111D33; /* fondo terciario (cards, inputs) */ +--border: #1E2D4A; /* bordes */ +--accent: #00D4FF; /* azul cian — color principal */ +--accent2: #00FF9D; /* verde — éxito */ +--warn: #FFB800; /* amarillo — advertencia */ +--err: #FF4757; /* rojo — error */ +--text: #C8D8F0; /* texto principal */ +--text2: #6B89B0; /* texto secundario */ +``` + +**Fuentes:** +```html + + + +/* Body: Space Grotesk */ +/* Código, slugs, terminales: IBM Plex Mono */ +``` + +### Layout general + +``` +┌─────────────────────────────────────────────┐ +│ ⚙️ PymesBot / installer v1.0.0 │ ← header fijo +├────────────────┬────────────────────────────┤ +│ │ │ +│ SIDEBAR │ CONTENIDO DEL PASO │ +│ (240px) │ (flexible) │ +│ │ │ +│ ○ Paso 1 │ │ +│ ○ Paso 2 │ │ +│ ● Paso 3 ◄ │ │ +│ ○ Paso 4 │ │ +│ ○ Paso 5 │ │ +│ ○ Paso 6 │ │ +│ │ │ +└────────────────┴────────────────────────────┘ +``` + +Los pasos completados tienen ícono ✓ verde. El paso actual tiene una línea de color en el borde izquierdo y el texto en blanco. Los pasos futuros tienen el número y texto en gris. + +### PASO 0 — Detección del entorno + +**Trigger:** se ejecuta automáticamente al cargar la página (si el token es válido). + +**Mientras carga:** +- Spinner animado en el centro +- Texto: `"Analizando el servidor..."` + +**Cuando termina:** +- Grid 2x3 de tarjetas, una por componente detectado: + - Docker ✅ v24.0.7 / ❌ no encontrado + - Nginx ✅ / ❌ + - Certbot ✅ / ❌ + - /opt/pymesbot ✅ existe / ❌ no existe + - Red Docker pymesbot_net ✅ / ❌ + - Clientes activos: `N instancias` + +- Caja de modo detectado (color según el modo): + - **Modo A** (fondo verde oscuro): `"VPS limpia detectada. Se instalará la infraestructura base y luego el primer cliente."` + - **Modo B** (fondo azul oscuro): `"Infraestructura detectada. N clientes activos. Se dará de alta un cliente nuevo."` + +- Botón `[Continuar →]` + +**API call:** `GET /install/detect` + +--- + +### PASO 1 — Autenticación sudo + +**Campos:** +- Input `type="password"` con label `"Contraseña sudo del servidor"` +- Texto de ayuda: `"La contraseña se usa solo durante esta sesión para configurar el servidor. No se guarda en ningún archivo."` + +**Al hacer click en "Verificar permisos":** +1. Deshabilitar el botón +2. Mostrar spinner en el botón +3. `POST /install/validate-sudo` con `{ password: "..." }` +4. Si responde `{ ok: true }`: avanzar al paso 2 +5. Si responde `{ ok: false }`: mostrar el mensaje de error debajo del input, habilitar el botón + +--- + +### PASO 2 — Datos del servidor (solo Modo A) + +**Este paso aparece SOLO si el modo detectado es A.** +Si es Modo B, saltar directo al paso 3. + +**Campos:** +| Campo | Tipo | Placeholder | Validación | +|-------|------|-------------|-----------| +| Dominio raíz | text | `rvconsultas.com` | Formato de dominio válido, sin `http://` ni `/` | +| Email SSL | email | `admin@rvconsultas.com` | Email válido | +| API Key Anthropic | password | `sk-ant-...` | Debe empezar con `sk-ant-` | + +**Al continuar:** guardar los valores en memoria del wizard (variables JS). + +--- + +### PASO 3 — Datos del cliente nuevo + +**Campos:** + +**Nombre del negocio** (text, requerido) +- Al escribir: auto-generar el slug en tiempo real (ver lógica abajo) + +**Slug** (text, requerido) +- Se auto-llena mientras el usuario escribe el nombre +- Editable manualmente +- Al perder el foco (onblur): `POST /install/check-slug` con el slug +- Mostrar debajo: `castillo.rvconsultas.com ✓ disponible` (verde) o `❌ ya existe` (rojo) + +**Lógica de auto-generación del slug (JS):** +```javascript +function nombreASlug(nombre) { + return nombre + .toLowerCase() + .replace(/[áàä]/g, 'a').replace(/[éèë]/g, 'e') + .replace(/[íìï]/g, 'i').replace(/[óòö]/g, 'o') + .replace(/[úùü]/g, 'u').replace(/ñ/g, 'n') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} +``` + +**Rubro** (select) +- Librería / Papelería +- Kiosco +- Bazar +- Otro + +**Moneda** (select) +- 🇦🇷 ARS — Peso argentino +- 🇺🇸 USD — Dólar +- 🇺🇾 UYU — Peso uruguayo + +**Email del dueño** (email, opcional) +- Texto de ayuda: `"Se envían las credenciales de acceso a esta dirección."` + +--- + +### PASO 4 — Confirmación antes de ejecutar + +**Muestra un resumen de acciones:** + +Lista numerada de las acciones que se van a realizar (adaptada según el modo): + +**Modo A:** +1. Actualizar repositorios del sistema +2. Instalar Nginx y Certbot +3. Crear estructura /opt/pymesbot/ +4. Crear red Docker pymesbot_net +5. Crear estructura del cliente '{nombre}' +6. Generar archivos de configuración +7. Inicializar base de datos SQLite +8. Configurar subdominio Nginx ({slug}.{dominio}) +9. Emitir certificado SSL (requiere que el DNS ya apunte a este servidor) +10. Levantar contenedores Docker +11. Verificar que el servicio responde + +**Modo B:** solo los pasos 5 en adelante. + +**Checkbox:** `☐ Entiendo que esta operación modificará la configuración del servidor` + +**Botón "Ejecutar":** deshabilitado hasta que el checkbox esté marcado. Al hacer click: +1. `POST /install/start` con toda la config +2. Si responde con `session_id`: avanzar al paso 5 y conectar el WebSocket + +--- + +### PASO 5 — Ejecución con progreso en tiempo real + +**Barra de progreso** (0% → 100%, animada) + +**Lista de pasos** — se van actualizando en tiempo real con el WebSocket: +- ⬜ paso pendiente (gris) +- 🔄 paso en ejecución (ícono girando animado) +- ✅ paso completado (verde + duración en ms) +- ❌ paso con error (rojo + mensaje de error expandible + hint) + +**Panel de output** (colapsable, debajo de cada paso): +- Fondo oscuro tipo terminal +- Muestra el output raw del comando (los eventos `type: "output"`) +- El usuario puede hacer click en "ver detalles" para expandirlo + +**Si hay un error:** +- El paso se marca con ❌ +- Aparece el mensaje de error y el hint en rojo +- Aparece un botón `[Reintentar desde este paso]` +- La barra de progreso se detiene + +**WebSocket:** +```javascript +const ws = new WebSocket(`ws://${location.host}/install/ws/${sessionId}`); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + switch(data.type) { + case 'step_start': // mostrar paso como 🔄 + case 'output': // agregar línea al panel de output de ese paso + case 'step_done': // marcar como ✅ con la duración + case 'step_error': // marcar como ❌, mostrar error y hint + case 'done': // avanzar automáticamente al paso 6 + case 'fatal_error': // mostrar error crítico con opción de reintentar todo + } +}; +``` + +--- + +### PASO 6 — Resumen final (éxito) + +**Animación de entrada:** el número del check animado con un efecto de escala + +**Contenido:** +- `🎉` grande animado +- `"¡{NOMBRE} está activo!"` +- Link clickeable: `https://{slug}.{dominio}` (se abre en nueva pestaña) + +**Tarjetas de credenciales** (3 en fila): +- Usuario admin: `admin` +- Contraseña: `Xk7mQ2p` (con botón de copiar individual) +- PIN vendedor: `4821` + +**Advertencia destacada** (fondo amarillo): +``` +⚠️ Cerrá el instalador cuando termines: +docker compose down +``` + +**Botones:** +- `[📋 Copiar todas las credenciales]` → copia un texto formateado al portapapeles +- `[📧 Enviar por email]` → si se configuró un email en el paso 3 (sino, deshabilitado) +- `[⬇️ Descargar resumen]` → descarga un `.txt` con las credenciales + +**Al fondo (separado):** +- `[➕ Dar de alta otro cliente]` → vuelve al paso 3 con todo limpio + +--- + +## 12. Templates Jinja2 que genera el instalador + +### `env_client.j2` + +```jinja2 +# Generado automáticamente por PymesBot Installer +# No editar manualmente. Regenerar con el instalador. + +ANTHROPIC_API_KEY={{ anthropic_api_key }} +CLIENTE_NOMBRE={{ cliente_nombre }} +CLIENTE_ID={{ cliente_id }} +PUERTO={{ puerto }} +ADMIN_PASSWORD={{ admin_password }} +VENDOR_PIN={{ vendor_pin }} +DOMINIO_BASE={{ dominio_base }} +``` + +### `docker-compose.client.yml.j2` + +```jinja2 +version: '3.8' + +services: + + backend_{{ slug }}: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pymesbot_{{ slug }} + restart: always + ports: + - "${PUERTO}:8000" + volumes: + - ./data:/app/data + env_file: + - .env + networks: + - pymesbot_net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + picoclaw_{{ slug }}: + image: ghcr.io/sipeed/picoclaw:latest + container_name: picoclaw_{{ slug }} + restart: always + volumes: + - ./picoclaw:/root/.picoclaw + - ./data:/data:ro + depends_on: + backend_{{ slug }}: + condition: service_healthy + networks: + - pymesbot_net + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + +networks: + pymesbot_net: + external: true +``` + +### `picoclaw_config.json.j2` + +```jinja2 +{ + "agents": { + "defaults": { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 1000, + "temperature": 0.3, + "max_tool_iterations": 5, + "system": "Sos el asistente de ventas de {{ nombre_negocio }}, una {{ rubro }} en Argentina.\nTu trabajo es ayudar al vendedor a atender clientes rápido y con información exacta.\n\nHERRAMIENTAS DISPONIBLES:\n- buscar_stock: busca productos por nombre\n- confirmar_venta: registra una venta y descuenta el stock\n- armar_combo: genera lista de productos para un presupuesto\n- ver_promociones: muestra las promos activas de hoy\n\nREGLAS OBLIGATORIAS:\n1. NUNCA des precios aproximados. Siempre usá buscar_stock.\n2. Si no hay stock, ofrecé siempre la alternativa más cercana.\n3. Al armar combos: preguntá presupuesto, edad, género (optativo).\n4. Al final de cada consulta: preguntá si se concretó la venta.\n5. Si confirman la venta: llamá a confirmar_venta inmediatamente.\n6. Respondé en español argentino coloquial pero profesional.\n7. Sé conciso: máximo 3-4 líneas por respuesta.\n8. Nunca menciones otros negocios ni competidores." + } + }, + "providers": { + "anthropic": { + "api_key": "${ANTHROPIC_API_KEY}" + } + } +} +``` + +### `nginx_site.conf.j2` + +```jinja2 +server { + listen 80; + server_name {{ slug }}.{{ dominio }}; + + location / { + proxy_pass http://127.0.0.1:{{ puerto }}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} +``` + +### `schema.sql` (no es template Jinja2, es SQL puro) + +Ver la sección 7 del documento `01_PYMESBOT_PROJECT_SPEC.md` para el SQL completo. + +--- + +## 13. Seguridad + +### Token de acceso (`app/security.py`) + +```python +# app/security.py +import secrets +import os + +# Generar token al importar el módulo (una vez al arrancar el contenedor) +INSTALL_TOKEN = secrets.token_urlsafe(16) + +async def validar_sudo(password: str) -> tuple[bool, str]: + """ + Verifica que la contraseña sudo sea correcta. + Usa 'sudo -n' primero para no lockear la cuenta. + Returns: (ok: bool, error_msg: str) + """ + from .detect import run_cmd + + # Verificar con un comando inofensivo: whoami + rc, stdout, stderr = await run_cmd( + f"echo '{password}' | sudo -S whoami", + timeout=10 + ) + + if rc == 0 and stdout.strip() == "root": + return True, "" + else: + if "incorrect password" in stderr.lower() or "no passwd" in stderr.lower(): + return False, "Contraseña incorrecta" + elif "not in the sudoers" in stderr.lower(): + return False, "Este usuario no tiene permisos sudo" + else: + return False, f"Error al verificar: {stderr.strip()}" +``` + +### Estado de sesión (`app/state.py`) + +```python +# app/state.py +# Estado en memoria. Se pierde al reiniciar el contenedor. +# NUNCA persistir la contraseña sudo en disco. + +import time +from typing import Any, Optional + +_store: dict = {} +_timestamps: dict = {} +SESSION_TIMEOUT = 1800 # 30 minutos + +def save_session(key: str, value: Any) -> None: + _store[key] = value + _timestamps[key] = time.time() + +def get_session(key: str) -> Optional[Any]: + if key not in _store: + return None + # Verificar expiración + if time.time() - _timestamps[key] > SESSION_TIMEOUT: + del _store[key] + del _timestamps[key] + return None + return _store[key] + +def clear_session(key: str) -> None: + _store.pop(key, None) + _timestamps.pop(key, None) +``` + +### Reglas de seguridad del instalador + +1. **La contraseña sudo NUNCA se loguea.** Ni en `print()`, ni en logs de uvicorn, ni en archivos. Usar `{SUDO_PASSWORD}` en comandos sin loguear la variable. +2. **El token de acceso se regenera cada vez que el contenedor arranca.** No hay forma de reutilizar un token viejo. +3. **Sin token, `/install` devuelve 404**, no 401 ni 403. No revelar que existe el endpoint. +4. **El instalador no escucha en puertos que no sea el 80.** Nginx del host tampoco debe proxear el puerto del instalador una vez que termina. +5. **`restart: "no"` en el docker-compose.** El instalador no se reinicia solo. +6. **No hay endpoint de API sin verificación de token.** Absolutamente todos los endpoints verifican el token. + +--- + +## 14. Testing y validación + +### Cómo testear el instalador localmente + +Para testear sin un servidor real, usar Docker-in-Docker: + +```bash +# Levantar un Ubuntu limpio con Docker disponible +docker run -it --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8000 \ + ubuntu:24.04 bash + +# Dentro del contenedor: instalar Docker y Python +apt-get update && apt-get install -y docker.io python3 python3-pip curl + +# Simular que es un VPS limpio +# (docker ya está, nginx y certbot no) + +# Desde afuera: levantar el instalador apuntando al Ubuntu +docker compose up -d +# Entrar a http://localhost:8080/install?token=... +``` + +### Checklist de validación antes de marcar como completo + +**Detección:** +- [ ] Detecta correctamente cuando Docker no está instalado +- [ ] Detecta correctamente cuando Nginx sí está instalado +- [ ] Lista correctamente los clientes existentes en /opt/pymesbot +- [ ] Determina el modo correcto (A, B o C) +- [ ] Encuentra el próximo puerto disponible correctamente + +**Wizard UI:** +- [ ] El spinner de detección aparece y desaparece +- [ ] Los 6 pasos del sidebar se marcan correctamente +- [ ] El auto-complete del slug funciona con acentos y ñ +- [ ] La verificación de slug disponible funciona en tiempo real +- [ ] El checkbox de confirmación habilita el botón de ejecutar +- [ ] El WebSocket muestra el progreso en tiempo real +- [ ] Los errores de steps muestran el hint correcto +- [ ] Las credenciales se muestran en el paso 6 + +**Seguridad:** +- [ ] Sin token en la URL, `/install` devuelve 404 +- [ ] Con token incorrecto, `/install` devuelve 404 +- [ ] La contraseña sudo no aparece en los logs de uvicorn +- [ ] La contraseña sudo no se guarda en ningún archivo + +**Idempotencia:** +- [ ] Correr el instalador dos veces para el mismo slug falla graciosamente con Modo C +- [ ] Crear la red Docker cuando ya existe no falla +- [ ] `mkdir -p` no falla si la carpeta ya existe + +--- + +## 15. Orden de construcción recomendado + +Construir en este orden exacto. Cada paso debe funcionar antes de seguir con el siguiente. + +### Sprint 1: Fundación (empezar acá) + +1. **`detect.py`**: implementar `detectar_entorno()` y `run_cmd()`. Testear localmente que detecta bien Docker y Nginx. +2. **`main.py` esqueleto**: FastAPI con las rutas `/install`, `/install/detect`, y el token de seguridad. Probar que sin token devuelve 404. +3. **`static/index.html`** paso 0: solo el paso de detección. Que llame a `/install/detect` y muestre las tarjetas. Sin el resto del wizard. + +### Sprint 2: Wizard completo (sin ejecutar nada real) + +4. Completar los **6 pasos del wizard** con datos hardcodeados o mock. El wizard debe navegar entre pasos, validar campos, mostrar/ocultar según el modo. +5. Conectar el **WebSocket** de progreso con datos mock (simular que los pasos pasan de ⬜ a 🔄 a ✅ con delays). + +### Sprint 3: Backend real + +6. **`security.py`**: validación sudo real con `run_cmd`. +7. **`templates_engine.py`**: generar los 4 archivos de configuración. Testear que el output es correcto para un cliente de ejemplo. +8. **`client_setup.py`**: implementar los 9 pasos de C1 a C9. Testear en un servidor de prueba real. +9. **`installer.py`**: implementar los 5 pasos de A1 a A5. Testear en un Ubuntu limpio. + +### Sprint 4: Integración y pulido + +10. Conectar el wizard real con el backend real (reemplazar los mocks del Sprint 2). +11. Implementar el **rollback** completo. +12. Pasar el checklist de validación de la sección 14. +13. Construir la imagen Docker y publicar en el registry. + +--- + +*Fin del documento. Versión 1.0 — PymesBot Installer Spec*