Dockerfile
Rama main · réplica de lectura del repositorio original.
# LLMSwapper - imagen única para hosts Linux, macOS y Windows.
#
# El contenedor es SIEMPRE Linux; lo que cambia según el host es qué se puede montar y qué deja
# de funcionar. Está documentado en el README, y el propio panel lo dice en pantalla en vez de
# fingir que todo va bien: dentro de un contenedor no se ven los procesos del host (así que no
# puede saber si Claude Code está abierto) ni existe wsl.exe (así que no hay targets de WSL).
#
# node:22-alpine es multiarquitectura, así que la misma receta produce amd64 y arm64 - esto
# último importa: sin arm64 la imagen correría emulada en los Mac con Apple Silicon.
# Publicar ambas: docker buildx build --platform linux/amd64,linux/arm64 -t llmswapper .
FROM node:22-alpine
# Cero dependencias: no hay npm install, ni build, ni capa de node_modules que cachear.
# Por eso no hay multi-stage - no habría nada que dejar fuera de la segunda etapa.
WORKDIR /app
COPY package.json ./
COPY server.js test.js ./
COPY lib/ ./lib/
COPY public/ ./public/
# Sello de build. Un `docker compose up -d` sin --build reutiliza la imagen anterior sin decir
# nada, y desde fuera no hay forma de saber qué código corre: el panel lo enseña en el aviso de
# contenedor y /api/health lo devuelve. Va DETRÁS de los COPY para que la caché lo regenere
# exactamente cuando cambia el código, y nunca antes.
RUN date -u +%Y-%m-%dT%H:%MZ > /app/.build
# Lo lee lib/paths.inContainer(). /.dockerenv basta para Docker, pero no para Podman ni para
# algunos runtimes de Kubernetes, así que se declara explícitamente y no se deja a la deducción.
ENV SWAPPER_IN_CONTAINER=1
# 127.0.0.1 dentro del contenedor sería inalcanzable desde el host: hay que escuchar en todas
# las interfaces DEL CONTENEDOR. La propiedad de seguridad se conserva fuera, publicando el
# puerto solo en el loopback del host: -p 127.0.0.1:7373:7373
ENV SWAPPER_BIND=0.0.0.0
ENV PORT=7373
# Sin esto el servidor intenta abrir un navegador que no existe en la imagen.
ENV NO_OPEN=1
# El directorio tiene que existir Y pertenecer a node ANTES del VOLUME: al crear un volumen con
# nombre por primera vez, Docker copia dueño y permisos de la ruta en la imagen. Sin esto el
# volumen nace de root, el proceso corre como node, y el arranque muere con
# EACCES: permission denied, mkdir '/app/data/backups'. Solo vale para volúmenes con nombre: un
# bind mount (./data en el compose) trae el dueño del host, así que ahí data/ tiene que existir ya.
RUN mkdir -p /app/data && chown -R node:node /app
# data/ guarda tokens vivos. Declararlo como volumen evita que acabe en una capa de la imagen
# si alguien hace commit del contenedor, y hace evidente que hay estado que persistir.
VOLUME ["/app/data"]
# uid 1000 es el primer usuario en la mayoría de distros Linux, así que los ficheros montados
# suelen coincidir. Cuando no coincidan: --user "$(id -u):$(id -g)"
# HOME fijo: con --user, un uid que no está en /etc/passwd arranca con HOME=/ y el servidor
# buscaría /.claude.json y /.claude en vez de lo montado en /home/node.
ENV HOME=/home/node
USER node
EXPOSE 7373
# La API exige su propia cabecera en TODA petición, /api/health incluido - de ahí el --header.
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q -O- --header='X-Swapper: 1' "http://127.0.0.1:${PORT}/api/health" >/dev/null || exit 1
CMD ["node", "server.js"]