49 KiB
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
- Qué es el instalador
- Cómo se usa
- Estructura de archivos completa
- Stack tecnológico
- El archivo que descarga el operador
- Módulo detect.py — detección del entorno
- Módulo installer.py — instalación base
- Módulo client_setup.py — alta de cliente
- Módulo templates_engine.py — generador de configs
- Backend FastAPI — rutas y WebSocket
- El wizard HTML — especificación completa
- Templates Jinja2 que genera el instalador
- Seguridad
- Testing y validación
- Orden de construcción recomendado
1. Qué es el instalador
El instalador es un contenedor Docker temporal que:
- Se levanta en el servidor con
docker compose up -d - Expone una interfaz web en
http://IP-DEL-SERVIDOR/install - El operador (Guillermo) abre esa URL en su navegador, responde preguntas
- El instalador detecta el estado del servidor y ejecuta las acciones necesarias
- Al terminar, muestra las credenciales del cliente nuevo
- 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,
/installdevuelve 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 -pno 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 -tfalla:step_errorcon 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 -pno falla si ya existe
Paso C2: generate_configs — Generar archivos de configuración
Llamar a templates_engine.py para generar:
/opt/pymesbot/{slug}/.env/opt/pymesbot/{slug}/docker-compose.yml/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
- Generar el archivo de configuración usando el template
nginx_site.conf.j2 - Escribirlo en
/etc/nginx/conf.d/{slug}.conf - Validar:
nginx -t - 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 -tfalla: 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)
--buildfuerza 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."
- Modo A (fondo verde oscuro):
-
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":
- Deshabilitar el botón
- Mostrar spinner en el botón
POST /install/validate-sudocon{ password: "..." }- Si responde
{ ok: true }: avanzar al paso 2 - 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 | 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-slugcon 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:
- Actualizar repositorios del sistema
- Instalar Nginx y Certbot
- Crear estructura /opt/pymesbot/
- Crear red Docker pymesbot_net
- Crear estructura del cliente '{nombre}'
- Generar archivos de configuración
- Inicializar base de datos SQLite
- Configurar subdominio Nginx ({slug}.{dominio})
- Emitir certificado SSL (requiere que el DNS ya apunte a este servidor)
- Levantar contenedores Docker
- 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:
POST /install/startcon toda la config- 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.txtcon 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
- 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. - El token de acceso se regenera cada vez que el contenedor arranca. No hay forma de reutilizar un token viejo.
- Sin token,
/installdevuelve 404, no 401 ni 403. No revelar que existe el endpoint. - 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.
restart: "no"en el docker-compose. El instalador no se reinicia solo.- 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,
/installdevuelve 404 - Con token incorrecto,
/installdevuelve 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 -pno 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á)
detect.py: implementardetectar_entorno()yrun_cmd(). Testear localmente que detecta bien Docker y Nginx.main.pyesqueleto: FastAPI con las rutas/install,/install/detect, y el token de seguridad. Probar que sin token devuelve 404.static/index.htmlpaso 0: solo el paso de detección. Que llame a/install/detecty muestre las tarjetas. Sin el resto del wizard.
Sprint 2: Wizard completo (sin ejecutar nada real)
- 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.
- Conectar el WebSocket de progreso con datos mock (simular que los pasos pasan de ⬜ a 🔄 a ✅ con delays).
Sprint 3: Backend real
security.py: validación sudo real conrun_cmd.templates_engine.py: generar los 4 archivos de configuración. Testear que el output es correcto para un cliente de ejemplo.client_setup.py: implementar los 9 pasos de C1 a C9. Testear en un servidor de prueba real.installer.py: implementar los 5 pasos de A1 a A5. Testear en un Ubuntu limpio.
Sprint 4: Integración y pulido
- Conectar el wizard real con el backend real (reemplazar los mocks del Sprint 2).
- Implementar el rollback completo.
- Pasar el checklist de validación de la sección 14.
- Construir la imagen Docker y publicar en el registry.
Fin del documento. Versión 1.0 — PymesBot Installer Spec