Files
demo/02_PYMESBOT_INSTALLER_SPEC.md

49 KiB
Raw Blame History

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
  2. Cómo se usa
  3. Estructura de archivos completa
  4. Stack tecnológico
  5. El archivo que descarga el operador
  6. Módulo detect.py — detección del entorno
  7. Módulo installer.py — instalación base
  8. Módulo client_setup.py — alta de cliente
  9. Módulo templates_engine.py — generador de configs
  10. Backend FastAPI — rutas y WebSocket
  11. El wizard HTML — especificación completa
  12. Templates Jinja2 que genera el instalador
  13. Seguridad
  14. Testing y validación
  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

# 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

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

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.

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

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

# 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

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

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.

# 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

# 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

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

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

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.

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

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

@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

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:

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:

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/.

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

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

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:

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:

{
    "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:

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.

# 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

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

--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:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">

/* 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):

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:

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

# 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

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

{
  "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

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)

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

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

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

  1. 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.
  2. Conectar el WebSocket de progreso con datos mock (simular que los pasos pasan de a 🔄 a con delays).

Sprint 3: Backend real

  1. security.py: validación sudo real con run_cmd.
  2. templates_engine.py: generar los 4 archivos de configuración. Testear que el output es correcto para un cliente de ejemplo.
  3. client_setup.py: implementar los 9 pasos de C1 a C9. Testear en un servidor de prueba real.
  4. installer.py: implementar los 5 pasos de A1 a A5. Testear en un Ubuntu limpio.

Sprint 4: Integración y pulido

  1. Conectar el wizard real con el backend real (reemplazar los mocks del Sprint 2).
  2. Implementar el rollback completo.
  3. Pasar el checklist de validación de la sección 14.
  4. Construir la imagen Docker y publicar en el registry.

Fin del documento. Versión 1.0 — PymesBot Installer Spec