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*