# 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*